"""Certificate-auth persisted-session metadata and redacted diagnostics.
:class:`adapters.outbound.aeat.auth.authenticator.AeatAuthenticator` writes
:class:`PersistedSessionMetadata` into the encrypted
:class:`adapters.outbound.aeat.auth.session_store.PersistedBrowserSession`
metadata mapping after capturing Playwright storage state. Resume paths use
the metadata to validate the storage-state fingerprint, idle deadline,
certificate thumbprint, and certificate subject before rebuilding the session.
The reason-code helpers reduce detailed invalidation causes to stable,
non-sensitive strings carried through :class:`AeatLoginAssertionError`.
"""
from __future__ import annotations
from datetime import datetime
from typing import Final, Literal
from pydantic import BaseModel, Field, field_validator
from .....core.config_support import AEAT_CERTIFICATE_PROTECTED_URL, assert_canonical_protected_resource
from .....core.errors.hierarchy import AeatLoginAssertionError, pydantic_validation_boundary
from .....core.identity.digest import ContentDigest
from .....core.models import STRICT_FROZEN_CONFIG
from .....core.time.utc import validate_utc_aware
from .....core.type_guards import is_str_keyed_dict
AEAT_STORAGE_STATE_SCHEMA_VERSION: Final[int] = 2
"""Schema version for certificate-auth :class:`PersistedSessionMetadata` records."""
# Ordered (substring, reason-code) rules for persisted-session refusals. The
# declaration order is specificity order: the first substring found in the
# lowered reason wins, mirroring the original if-ladder exactly.
_REASON_CODE_RULES: Final[tuple[tuple[str, str], ...]] = (
("hash does not match", "storage_hash_mismatch"),
("past its idle deadline", "idle_deadline_expired"),
("different certificate thumbprint", "certificate_thumbprint_mismatch"),
("different certificate subject", "certificate_subject_mismatch"),
("different certificate nif", "certificate_nif_mismatch"),
("failed live verification", "live_verification_failed"),
("could not be resumed", "resume_failed"),
("storage_state missing", "storage_state_missing"),
("storage_state is malformed", "storage_state_malformed"),
("storage_state root", "storage_state_root_invalid"),
("cookies array", "storage_state_cookies_missing"),
("origins array", "storage_state_origins_missing"),
("metadata is malformed", "metadata_malformed"),
("schema version", "schema_version_unsupported"),
)
[docs]
def persisted_session_reason_code(reason: str) -> str:
"""Map a detailed persisted-session refusal reason to a non-sensitive code.
The mapping mirrors the certificate-auth resume gates and storage-state
parsing checks so callers can log or translate the outcome without exposing
certificate subjects, logical storage paths, or browser-session contents.
"""
reason_lower = reason.lower()
for needle, code in _REASON_CODE_RULES:
if needle in reason_lower:
return code
return "invalid_persisted_session"
[docs]
def persisted_session_reason_from_error(error: AeatLoginAssertionError) -> str:
"""Extract the redacted persisted-session reason code from an auth error.
Returns the explicit ``context["reason"]`` value when
:class:`AeatLoginAssertionError` carries one, otherwise falls back to the
generic persisted-session invalidation code.
"""
context = getattr(error, "context", None)
if is_str_keyed_dict(context):
reason = context.get("reason")
if isinstance(reason, str) and reason:
return reason
return "invalid_persisted_session"
__all__ = [
"PersistedSessionMetadata",
"persisted_session_reason_code",
"persisted_session_reason_from_error",
]