"""Authentication module exceptions."""
from __future__ import annotations
from typing import Any
from kstlib.config.exceptions import KstlibError
[docs]
class AuthError(KstlibError):
"""Base exception for all authentication errors."""
[docs]
def __init__(self, message: str, *, details: dict[str, Any] | None = None) -> None:
"""Initialize the auth error with a message and optional structured details."""
super().__init__(message)
self.message = message
self.details = details or {}
[docs]
class ConfigurationError(AuthError):
"""Raised when auth configuration is invalid or missing."""
[docs]
class ProviderNotFoundError(AuthError):
"""Raised when a named provider is not configured."""
[docs]
def __init__(self, provider_name: str) -> None:
"""Initialize with the name of the missing provider."""
super().__init__(f"Provider '{provider_name}' not found in configuration")
self.provider_name = provider_name
[docs]
class DiscoveryError(AuthError):
"""Raised when OIDC discovery fails."""
[docs]
def __init__(self, issuer: str, reason: str) -> None:
"""Initialize with the failing issuer URL and the reason for the failure."""
super().__init__(f"Discovery failed for '{issuer}': {reason}")
self.issuer = issuer
self.reason = reason
[docs]
class TokenError(AuthError):
"""Base exception for token-related errors."""
[docs]
class TokenExpiredError(TokenError):
"""Raised when a token has expired and cannot be refreshed."""
[docs]
class TokenRefreshError(TokenError):
"""Raised when token refresh fails."""
[docs]
def __init__(self, reason: str, *, retryable: bool = False) -> None:
"""Initialize with the reason for the refresh failure and a retryable flag."""
super().__init__(f"Token refresh failed: {reason}")
self.reason = reason
self.retryable = retryable
[docs]
class TokenExchangeError(TokenError):
"""Raised when authorization code exchange fails."""
[docs]
def __init__(self, reason: str, *, error_code: str | None = None) -> None:
"""Initialize with the reason for the exchange failure and an optional OAuth error code."""
super().__init__(f"Token exchange failed: {reason}")
self.reason = reason
self.error_code = error_code
[docs]
class TokenValidationError(TokenError):
"""Raised when JWT validation fails (signature, claims, expiry)."""
[docs]
def __init__(self, reason: str, *, claim: str | None = None) -> None:
"""Initialize with the reason for the validation failure and the offending claim name."""
super().__init__(f"Token validation failed: {reason}")
self.reason = reason
self.claim = claim
[docs]
class TokenStorageError(TokenError):
"""Raised when token persistence fails (save/load/delete)."""
[docs]
class AuthorizationError(AuthError):
"""Raised during authorization flow failures."""
[docs]
def __init__(
self,
reason: str,
*,
error_code: str | None = None,
error_description: str | None = None,
) -> None:
"""Initialize with the reason for the failure plus optional OAuth error code and description."""
super().__init__(f"Authorization failed: {reason}")
self.reason = reason
self.error_code = error_code
self.error_description = error_description
[docs]
class CallbackServerError(AuthError):
"""Raised when the local callback server fails to start or receive callback."""
[docs]
def __init__(self, reason: str, *, port: int | None = None) -> None:
"""Initialize with the reason for the callback server failure and the port that was in use."""
super().__init__(f"Callback server error: {reason}")
self.reason = reason
self.port = port
[docs]
class PreflightError(AuthError):
"""Raised when preflight validation fails."""
[docs]
def __init__(self, step: str, reason: str) -> None:
"""Initialize with the failing preflight step name and the reason for the failure."""
super().__init__(f"Preflight failed at '{step}': {reason}")
self.step = step
self.reason = reason
[docs]
class AuthExpiredError(AuthError):
"""Raised when an authenticated request returns HTTP 401 indicating token expiration.
Surfaced by ``kstlib.rapi.client`` (and any other consumer) when a
server response signals that the previously-valid access token has
expired or been invalidated during the session. The user must
re-authenticate via the appropriate channel (for example,
``sas-admin auth login`` for Viya, or via a dedicated OAuth client
when configured in :mod:`kstlib.auth`).
Note:
Distinct from :class:`TokenExpiredError`. The two cover
different lifecycle points and originate from different
sub-systems :
- ``AuthExpiredError`` (this class, inherits from
:class:`AuthError`) is raised by ``kstlib.rapi.client`` when
the server returns HTTP 401 at runtime, signalling that a
token which was valid at send time has been expired or
invalidated by the identity provider during the session.
- :class:`TokenExpiredError` (inherits from :class:`TokenError`)
is raised by ``kstlib.auth`` when a loaded token is detected
as already expired before the request is sent (client-side
pre-flight check).
Attributes:
token_source: Optional label identifying where the token was
loaded from (for example, ``'~/.sas/credentials.json'``,
``'env:KSTLIB_TOKEN'``, ``'sops:secrets/api.sops.json'``).
``None`` when the source is unknown.
suggested_action: Optional human-readable hint guiding the
user toward a successful re-authentication (for example,
``'Run: sas-admin auth login -u <user>'``). ``None`` when
no contextual hint is available.
Examples:
>>> err = AuthExpiredError(
... "Access token expired (HTTP 401).",
... token_source="~/.sas/credentials.json",
... suggested_action="Run: sas-admin auth login -u <user>",
... )
>>> err.token_source
'~/.sas/credentials.json'
>>> isinstance(err, AuthError)
True
"""
[docs]
def __init__(
self,
message: str,
*,
token_source: str | None = None,
suggested_action: str | None = None,
) -> None:
"""Initialize AuthExpiredError.
Args:
message: Human-readable description of the expiration
(typically including the HTTP status and a short
rationale, never the raw token or response body).
token_source: Optional label for where the token came from
(used by callers to surface a contextual hint without
exposing the secret material itself).
suggested_action: Optional hint pointing the user to the
right re-authentication procedure.
"""
super().__init__(
message,
details={
"token_source": token_source,
"suggested_action": suggested_action,
},
)
self.token_source = token_source
self.suggested_action = suggested_action
__all__ = [
"AuthError",
"AuthExpiredError",
"AuthorizationError",
"CallbackServerError",
"ConfigurationError",
"DiscoveryError",
"PreflightError",
"ProviderNotFoundError",
"TokenError",
"TokenExchangeError",
"TokenExpiredError",
"TokenRefreshError",
"TokenStorageError",
"TokenValidationError",
]