# Copyright (c) 2024 Matthew Baker. All rights reserved. Licenced under the Apache Licence 2.0. See LICENSE file
from __future__ import annotations
from enum import Enum, auto
from typing import Any
[docs]
class ErrorCode(Enum):
"""
Indicates the type of error reported by :class:`crossauth_backend.CrossauthError`
"""
UserNotExist = auto()
""" Thrown when a given username does not exist, eg during login """
PasswordInvalid = auto()
""" Thrown when a password does not match, eg during login or signup """
EmailNotExist = auto()
""" Thrown when a a password reset is requested and the email does not
exist """
UsernameOrPasswordInvalid = auto()
""" For endpoints provided by servers in this package, this is returned
instead of UserNotExist or PasswordNotMatch, for security reasons """
InvalidClientId = auto()
""" This is returned if an OAuth2 client id is invalid """
ClientExists = auto()
""" This is returned if attempting to make a client which already exists
(client_id or name/userid) """
InvalidClientSecret = auto()
""" This is returned if an OAuth2 client secret is invalid """
InvalidClientIdOrSecret = auto()
""" Server endpoints in this package will return this instead of
InvalidClientId or InvalidClientSecret for security purposes """
InvalidRedirectUri = auto()
""" This is returned a request is made with a redirect Uri that is not
registered """
InvalidOAuthFlow = auto()
""" This is returned a request is made with a an oauth flow name that is
not recognized """
UserNotActive = auto()
""" Thrown on login attempt with a user account marked inactive """
EmailNotVerified = auto()
""" Thrown on login attempt with a user account marked not having had the
email address validated """
TwoFactorIncomplete = auto()
""" Thrown on login attempt with a user account marked not having
completed 2FA setup """
Unauthorized = auto()
""" Thrown when a resource expecting user authorization was access and
authorization not provided or wrong """
UnauthorizedClient = auto()
""" Thrown for the OAuth unauthorized_client error (when the client is
unauthorized as opposed to the user) """
InvalidScope = auto()
""" Thrown for the OAuth invalid_scope error """
InsufficientScope = auto()
""" Thrown for the OAuth insufficient_scope error """
InsufficientPriviledges = auto()
""" Returned if user is valid but doesn't have permission to access
resource """
Forbidden = auto()
""" Returned with an HTTP 403 response """
InvalidKey = auto()
"""
Thrown when a session or API key was provided that is not in the key
table.
For CSRF and sesison key, an InvalidCsrf or InvalidSession will be thrown
instead
"""
InvalidCsrf = auto()
""" Thrown if the CSRF token is invalid """
InvalidSession = auto()
""" Thrown if the session cookie is invalid """
Expired = auto()
""" Thrown when a session or API key has expired """
Connection = auto()
""" Thrown when there is a connection error, eg to a database """
InvalidHash = auto()
""" Thrown when a hash, eg password, is not in the given format """
UnsupportedAlgorithm = auto()
""" Thrown when an algorithm is requested but not supported, eg hashing
algorithm """
KeyExists = auto()
""" Thrown if you try to create a key which already exists in key
storage """
PasswordChangeNeeded = auto()
""" Thrown if the user needs to reset his or her password """
PasswordResetNeeded = auto()
""" Thrown if the user needs to reset his or her password """
Factor2ResetNeeded = auto()
""" Thrown if the user needs to reset factor2 before logging in """
Configuration = auto()
""" Thrown when something is missing or inconsistent in configuration """
InvalidEmail = auto()
""" Thrown if an email address in invalid """
InvalidPhoneNumber = auto()
""" Thrown if a phone number in invalid """
InvalidUsername = auto()
""" Thrown if an email address in invalid """
PasswordMatch = auto()
""" Thrown when two passwords do not match each other (eg signup) """
InvalidToken = auto()
""" Thrown when a token (eg TOTP or OTP) is invalid """
MfaRequired = auto()
""" Thrown during OAuth password flow if an MFA step is needed """
PasswordFormat = auto()
""" Thrown when a password does not match rules (length,
uppercase/lowercase/digits) """
DataFormat = auto()
""" Thrown when a the data field of key storage is not valid json """
FetchError = auto()
""" Thrown if a fetch failed """
UserExists = auto()
""" Thrown when attempting to create a user that already exists """
FormEntry = auto()
""" Thrown by user-supplied validation functions if a user details form
was incorrectly filled out """
BadRequest = auto()
""" Thrown when an invalid request is made, eg configure 2FA when 2FA is
switched off for user """
AuthorizationPending = auto()
""" Thrown in the OAuth device code flow """
SlowDown = auto()
""" Thrown in the OAuth device code flow """
ExpiredToken = auto()
""" Thrown in the OAuth device code flow """
ConstraintViolation = auto()
""" Thrown in database handlers where an insert causes a constraint
violation """
NotImplemented = auto()
""" Thrown if a method is unimplemented, typically when a feature
is not yet supported in this language. """
ValueError = auto()
""" Thrown a dict field is unexpectedly missing or wrong type """
UnknownError = auto()
""" Thrown for an condition not convered above. """
_FRIENDLY_HTTP_STATUS : dict[str, str] = {
'200': 'OK',
'201': 'Created',
'202': 'Accepted',
'203': 'Non-Authoritative Information',
'204': 'No Content',
'205': 'Reset Content',
'206': 'Partial Content',
'300': 'Multiple Choices',
'301': 'Moved Permanently',
'302': 'Found',
'303': 'See Other',
'304': 'Not Modified',
'305': 'Use Proxy',
'306': 'Unused',
'307': 'Temporary Redirect',
'400': 'Bad Request',
'401': 'Unauthorized',
'402': 'Payment Required',
'403': 'Forbidden',
'404': 'Not Found',
'405': 'Method Not Allowed',
'406': 'Not Acceptable',
'407': 'Proxy Authentication Required',
'408': 'Request Timeout',
'409': 'Conflict',
'410': 'Gone',
'411': 'Length Required',
'412': 'Precondition Required',
'413': 'Request Entry Too Large',
'414': 'Request-URI Too Long',
'415': 'Unsupported Media Type',
'416': 'Requested Range Not Satisfiable',
'417': 'Expectation Failed',
'418': 'I\'m a teapot',
'429': 'Too Many Requests',
'500': 'Internal Server Error',
'501': 'Not Implemented',
'502': 'Bad Gateway',
'503': 'Service Unavailable',
'504': 'Gateway Timeout',
'505': 'HTTP Version Not Supported',
}
[docs]
class CrossauthError(Exception):
"""
Thrown by Crossauth functions whenever it encounters an error.
"""
def __init__(self, code : ErrorCode, message : str | list[str] | None = None):
"""
Construct a CrossauthError object.
## Arguments
- :param ErrorCode code: Return this type of error
- :param str message: Return this method. If omitted, a default
will be returned. You can also return an array of messages
"""
_message : str | None = None
_http_status : int = 500
if (code == ErrorCode.UserNotExist):
_message = "User does not exist"
_http_status = 401
elif (code == ErrorCode.PasswordInvalid):
_message = "Password doesn't match"
_http_status = 401
elif (code == ErrorCode.UsernameOrPasswordInvalid):
_message = "Username or password incorrect"
_http_status = 401
elif (code == ErrorCode.InvalidClientId):
_message = "Client id is invalid"
_http_status = 401
elif (code == ErrorCode.ClientExists):
_message = "Client ID or name already exists"
_http_status = 500
elif (code == ErrorCode.InvalidClientSecret):
_message = "Client secret is invalid"
_http_status = 401
elif (code == ErrorCode.InvalidClientIdOrSecret):
_message = "Client id or secret is invalid"
_http_status = 401
elif (code == ErrorCode.InvalidRedirectUri):
_message = "Redirect Uri is not registered"
_http_status = 401
elif (code == ErrorCode.InvalidOAuthFlow):
_message = "Invalid OAuth flow type"
_http_status = 500
elif (code == ErrorCode.EmailNotExist):
_message = "No user exists with that email address"
_http_status = 401
elif (code == ErrorCode.UserNotActive):
_message = "Account is not active"
_http_status = 403
elif (code == ErrorCode.InvalidUsername):
_message = "Username is not in an allowed format"
_http_status = 400
elif (code == ErrorCode.InvalidEmail):
_message = "Email is not in an allowed format"
_http_status = 400
elif (code == ErrorCode.InvalidPhoneNumber):
_message = "Phone number is not in an allowed format"
_http_status = 400
elif (code == ErrorCode.EmailNotVerified):
_message = "Email address has not been verified"
_http_status = 403
elif (code == ErrorCode.TwoFactorIncomplete):
_message = "Two-factor setup is not complete"
_http_status = 403
elif (code == ErrorCode.Unauthorized):
_message = "Not authorized"
_http_status = 401
elif (code == ErrorCode.UnauthorizedClient):
_message = "Client not authorized"
_http_status = 401
elif (code == ErrorCode.InvalidScope):
_message = "Invalid scope"
_http_status = 403
elif (code == ErrorCode.InsufficientScope):
_message = "Insufficient scope"
_http_status = 403
elif (code == ErrorCode.Connection):
_message = "Connection failure"
elif (code == ErrorCode.Expired):
_message = "Token has expired"
_http_status = 401
elif (code == ErrorCode.InvalidHash):
_message = "Hash is not in a valid format"
elif (code == ErrorCode.InvalidKey):
_message = "Key is invalid"
_http_status = 401
elif (code == ErrorCode.Forbidden):
_message = "You do not have permission to access this resource"
_http_status = 403
elif (code == ErrorCode.InsufficientPriviledges):
_message = "You do not have the right privileges to access this "\
+ "resource"
_http_status = 401
elif (code == ErrorCode.InvalidCsrf):
_message = "CSRF token is invalid"
_http_status = 401
elif (code == ErrorCode.InvalidSession):
_message = "Session cookie is invalid"
_http_status = 401
elif (code == ErrorCode.UnsupportedAlgorithm):
_message = "Algorithm not supported"
elif (code == ErrorCode.KeyExists):
_message = "Attempt to create a key that already exists"
elif (code == ErrorCode.PasswordChangeNeeded):
_message = "User must change password"
_http_status = 403
elif (code == ErrorCode.PasswordResetNeeded):
_message = "User must reset password"
_http_status = 403
elif (code == ErrorCode.Factor2ResetNeeded):
_message = "User must reset 2FA"
_http_status = 403
elif (code == ErrorCode.Configuration):
_message = "There was an error in the configuration"
elif (code == ErrorCode.PasswordMatch):
_message = "Passwords do not match"
_http_status = 401
elif (code == ErrorCode.InvalidToken):
_message = "Token is not valid"
_http_status = 401
elif (code == ErrorCode.MfaRequired):
_message = "MFA is required"
_http_status = 401
elif (code == ErrorCode.PasswordFormat):
_message = "Password format was incorrect"
_http_status = 401
elif (code == ErrorCode.UserExists):
_message = "User already exists"
_http_status = 400
elif (code == ErrorCode.BadRequest):
_message = "The request is invalid"
_http_status = 400
elif (code == ErrorCode.DataFormat):
_message = "Session data has unexpected format"
_http_status = 500
elif (code == ErrorCode.FetchError):
_message = "Couldn't execute a fetch"
_http_status = 500
elif (code == ErrorCode.AuthorizationPending):
_message = "Waiting for authorization"
_http_status = 200
elif (code == ErrorCode.SlowDown):
_message = "Slow polling down by 5 seconds"
_http_status = 200
elif (code == ErrorCode.ExpiredToken):
_message = "Token has expired"
_http_status = 401
elif (code == ErrorCode.ConstraintViolation):
_message = "Database update/insert caused a constraint violation"
_http_status = 500
elif (code == ErrorCode.NotImplemented):
_message = "This method has not been implemented"
_http_status = 500
elif (code == ErrorCode.ValueError):
_message = "Field is missing or wrong type"
_http_status = 500
else:
_message = "Unknown error"
_http_status = 500
self.messages : list[str] | None = None
if (message != None and type(message) is str):
_message = message
self.messages = [message]
elif (type(message) is list[str]):
_message = ".".join(message)
self.messages = message
# Call the base class constructor with the parameters it needs
super(CrossauthError, self).__init__(_message)
self.message : str = _message
self.http_status : int = _http_status
self.code : ErrorCode = code
[docs]
@staticmethod
def from_oauth_error(error : str, error_description: str | None = None) -> CrossauthError:
"""
OAuth defines certain error types. To convert the error in an OAuth
response into a CrossauthError object, call this function.
:param str error: as returned by an OAuth call (converted to an
:class:`ErrorCode`).
:param str error_description as returned by an OAuth call (put in the
`message`)
:return a `crossauth_backend.CrossauthError` instance.
"""
code = ErrorCode.UnknownError
match error:
case "invalid_request": code = ErrorCode.BadRequest
case "unauthorized_client": code = ErrorCode.UnauthorizedClient
case "access_denied": code = ErrorCode.Unauthorized
case "unsupported_response_type": code = ErrorCode.BadRequest
case "invalid_scope": code = ErrorCode.InvalidScope
case "server_error": code = ErrorCode.UnknownError
case "temporarily_unavailable": code = ErrorCode.Connection
case "invalid_token": code = ErrorCode.InvalidToken
case "expired_token": code = ErrorCode.ExpiredToken
case "insufficient_scope": code = ErrorCode.InvalidToken
case "mfa_required": code = ErrorCode.MfaRequired
case "authorization_pending": code = ErrorCode.AuthorizationPending
case "slow_down": code = ErrorCode.SlowDown
case _: code = ErrorCode.UnknownError
return CrossauthError(code, error_description)
@property
def code_name(self):
""" Return the name of the error code """
return self.code.name
@property
def oauthErrorCode(self) -> str:
""" Return the OAuth name of an error code (eg "server_error")"""
match (self.code):
case ErrorCode.BadRequest: return "invalid_request"
case ErrorCode.UnauthorizedClient: return "unauthorized_client"
case ErrorCode.Unauthorized: return "access_denied"
case ErrorCode.InvalidScope: return "invalid_scope"
case ErrorCode.Connection: return "temporarily_unavailable"
case ErrorCode.InvalidToken: return "invalid_token"
case ErrorCode.MfaRequired: return "mfa_required"
case ErrorCode.AuthorizationPending: return "authorization_pending"
case ErrorCode.SlowDown: return "slow_down"
case ErrorCode.ExpiredToken: return "expired_token"
case ErrorCode.Expired: return "expired_token"
case _: return "server_error"
[docs]
@staticmethod
def as_crossauth_error(e : Any, default_message : str | None= None) -> CrossauthError:
"""
If the passed object is a `crossauth_backend.CrossauthError` instance, simply returns
it.
If not and it is an object with `errorCode` in it, creates a
CrossauthError from that and `errorMessage`, if present.
Otherwise creates a `crossauth_backend.CrossauthError` object with :class:`ErrorCode`
of `Unknown` from it, setting the `message` if possible.
:param Any e: the error to convert.
:param str|None default_message: message to use if there was none in the original exception.
:return: a :class:`crossauth_backend.CrossauthError` instance.
"""
if isinstance(e, CrossauthError):
return e
elif (isinstance(e, Exception)):
return CrossauthError(ErrorCode.UnknownError, str(e))
error_message = default_message if default_message is not None else ErrorCode.UnknownError.name
if 'message' in e:
error_message = e["message"]
return CrossauthError(ErrorCode.UnknownError, error_message)
[docs]
@staticmethod
def http_status_name(status : str|int) -> str:
"""
Returns the friendly name for an HTTP response code.
If it is not a recognized one, returns the friendly name for 500.
@param status the HTTP response code, which, while being numeric,
can be in a string or number.
@returns the string version of the response code.
"""
if (type(status) == int):
status = str(status)
if (status in _FRIENDLY_HTTP_STATUS):
return _FRIENDLY_HTTP_STATUS[status]
return _FRIENDLY_HTTP_STATUS['500']