Source code for litestar_security.accounts._refresh

"""Strict refresh-family rotation: commands, family store contract, and service.

This is the top of the refresh stack and the only module here that talks to a
store. It depends on the token value types and the receipt sealer; nothing in this
package depends back on it.
"""

from collections.abc import Callable, Mapping
from collections.abc import Set as AbstractSet
from dataclasses import dataclass, field
from datetime import datetime, timedelta
from secrets import token_bytes
from types import MappingProxyType
from typing import TYPE_CHECKING, Any, Generic, Protocol, TypeVar, cast, runtime_checkable

from litestar.exceptions import ImproperlyConfiguredException

from litestar_security.accounts._access_tokens import LocalAccessToken
from litestar_security.accounts._internal import (
    DIGEST_BYTES,
    LOOKUP_BYTES,
    aware_utc_time,
    encode_random,
    strict_context_text,
    utc_now,
    valid_identifier,
    valid_security_epoch,
)
from litestar_security.accounts._operations import (
    OUTCOME_ATTEMPTED,
    OUTCOME_CREATED,
    OUTCOME_REVOKED,
    REFRESH_CREATE,
    REFRESH_PREPARE,
    REFRESH_RECEIPT,
    REFRESH_REVOKE,
    REFRESH_ROTATE,
)
from litestar_security.accounts._rate_limits import RateLimited, RateLimitGuard, validate_rate_limits
from litestar_security.accounts._receipts import RefreshReceiptContext, RefreshReceiptReplay, RefreshReceiptSealer
from litestar_security.accounts._records import LocalAccountState, SecurityEvent
from litestar_security.accounts._refresh_tokens import (
    RefreshFamilyContext,
    RefreshRotationStatus,
    RefreshTokenCodec,
    RefreshTokenProof,
    TokenPair,
    normalize_refresh_scopes,
    valid_refresh_scope,
)
from litestar_security.authentication import InvalidCredentials, VerificationUnavailable
from litestar_security.context import AuthenticationEvidence

if TYPE_CHECKING:
    from litestar_security.accounts._access_tokens import LocalAccessTokenIssuer

__all__ = (
    "REFRESH_RESPONSE_HEADERS",
    "CreateRefreshFamilyCommand",
    "RefreshPreflightOutcome",
    "RefreshRotationOutcome",
    "RefreshTokenFamilyStore",
    "RefreshTokenService",
    "RotateRefreshCommand",
)

UserT = TypeVar("UserT")
_REFRESH_TOKEN_PREFIX = "rt_"  # noqa: S105 - public token namespace, not a credential
_REFRESH_FAMILY_PREFIX = "rf_"
_DEFAULT_REFRESH_IDLE_LIFETIME = timedelta(days=7)
_DEFAULT_REFRESH_ABSOLUTE_LIFETIME = timedelta(days=30)
_DEFAULT_REFRESH_RECEIPT_WINDOW = timedelta(seconds=30)
_MAXIMUM_REFRESH_RECEIPT_WINDOW = timedelta(seconds=30)
_MAXIMUM_RECEIPT_BYTES = 32_768
REFRESH_RESPONSE_HEADERS: "Mapping[str, str]" = MappingProxyType({"Cache-Control": "no-store", "Pragma": "no-cache"})


[docs] @dataclass(frozen=True, slots=True) class CreateRefreshFamilyCommand: """Initial opaque refresh token committed atomically with its family.""" token_id: str token_digest: bytes = field(repr=False) account_id: str family_id: str security_epoch: int created_at: "datetime" token_expires_at: "datetime" family_expires_at: "datetime" scopes: frozenset[str] = frozenset() evidence: AuthenticationEvidence | None = None def __post_init__(self) -> None: """Validate one complete atomic family creation candidate.""" try: created_at = aware_utc_time(self.created_at) token_expires_at = aware_utc_time(self.token_expires_at) family_expires_at = aware_utc_time(self.family_expires_at) except (AttributeError, ValueError): msg = "Refresh family timestamps must be timezone-aware" raise ValueError(msg) from None if ( not valid_identifier(self.token_id, prefix=_REFRESH_TOKEN_PREFIX) or self.token_digest.__class__ is not bytes or len(self.token_digest) != DIGEST_BYTES or not strict_context_text(self.account_id) or not valid_identifier(self.family_id, prefix=_REFRESH_FAMILY_PREFIX) or not valid_security_epoch(self.security_epoch) or not created_at < token_expires_at <= family_expires_at or any(not valid_refresh_scope(scope) for scope in self.scopes) or ( self.evidence is not None and not isinstance( # pyright: ignore[reportUnnecessaryIsInstance] - defend runtime store boundary self.evidence, AuthenticationEvidence ) ) ): msg = "Refresh family creation command is invalid" raise ValueError(msg) object.__setattr__(self, "created_at", created_at) object.__setattr__(self, "token_expires_at", token_expires_at) object.__setattr__(self, "family_expires_at", family_expires_at) object.__setattr__(self, "scopes", frozenset(self.scopes))
[docs] @dataclass(frozen=True, slots=True) class RotateRefreshCommand: """Candidate one-time refresh rotation passed to an atomic store.""" token_id: str token_digest: bytes = field(repr=False) account_id: str family_id: str security_epoch: int successor_id: str successor_digest: bytes = field(repr=False) successor_expires_at: "datetime" family_expires_at: "datetime" sealed_receipt: bytes = field(repr=False) receipt_expires_at: "datetime" idempotency_digest: bytes | None = field(default=None, repr=False) scopes: frozenset[str] = frozenset() evidence: AuthenticationEvidence | None = None def __post_init__(self) -> None: """Reject malformed storage material and contradictory deadlines.""" try: successor_expires_at = aware_utc_time(self.successor_expires_at) family_expires_at = aware_utc_time(self.family_expires_at) receipt_expires_at = aware_utc_time(self.receipt_expires_at) except (AttributeError, ValueError): msg = "Refresh rotation timestamps must be timezone-aware" raise ValueError(msg) from None if ( not valid_identifier(self.token_id, prefix=_REFRESH_TOKEN_PREFIX) or self.token_digest.__class__ is not bytes or len(self.token_digest) != DIGEST_BYTES or not strict_context_text(self.account_id) or not valid_identifier(self.family_id, prefix=_REFRESH_FAMILY_PREFIX) or not valid_security_epoch(self.security_epoch) or not valid_identifier(self.successor_id, prefix=_REFRESH_TOKEN_PREFIX) or self.successor_id == self.token_id or self.successor_digest.__class__ is not bytes or len(self.successor_digest) != DIGEST_BYTES or not successor_expires_at <= family_expires_at or receipt_expires_at > family_expires_at or self.sealed_receipt.__class__ is not bytes or not self.sealed_receipt or len(self.sealed_receipt) > _MAXIMUM_RECEIPT_BYTES or ( self.idempotency_digest is not None and (self.idempotency_digest.__class__ is not bytes or len(self.idempotency_digest) != DIGEST_BYTES) ) or any(not valid_refresh_scope(scope) for scope in self.scopes) or ( self.evidence is not None and not isinstance( # pyright: ignore[reportUnnecessaryIsInstance] - defend runtime store boundary self.evidence, AuthenticationEvidence ) ) ): msg = "Refresh rotation command or security epoch is invalid" raise ValueError(msg) object.__setattr__(self, "successor_expires_at", successor_expires_at) object.__setattr__(self, "family_expires_at", family_expires_at) object.__setattr__(self, "receipt_expires_at", receipt_expires_at) object.__setattr__(self, "scopes", frozenset(self.scopes))
[docs] @dataclass(frozen=True, slots=True) class RefreshRotationOutcome: """Atomic strict rotation, idempotent receipt, or replay outcome.""" status: RefreshRotationStatus sealed_receipt: bytes | None = field(default=None, repr=False) family_revoked: bool = False def __post_init__(self) -> None: """Reject contradictory receipt and revocation outcomes.""" if self.status.__class__ is not RefreshRotationStatus or self.family_revoked.__class__ is not bool: msg = "Refresh rotation outcome is invalid" raise ValueError(msg) receipt_status = self.status in {RefreshRotationStatus.ROTATED, RefreshRotationStatus.IDEMPOTENT_REPLAY} if ( receipt_status != (self.sealed_receipt is not None) or (receipt_status and self.family_revoked) or ( self.sealed_receipt is not None and ( self.sealed_receipt.__class__ is not bytes or not self.sealed_receipt or len(self.sealed_receipt) > _MAXIMUM_RECEIPT_BYTES ) ) ): msg = "Successful refresh rotation outcomes require exactly one sealed receipt" raise ValueError(msg) revoked_status = self.status in {RefreshRotationStatus.REPLAY_DETECTED, RefreshRotationStatus.REVOKED} if revoked_status != self.family_revoked: msg = "Replay or revoked refresh outcomes must report family revocation" raise ValueError(msg)
[docs] @dataclass(frozen=True, slots=True) class RefreshPreflightOutcome: """Proof-checked negative preflight outcome with exact revocation evidence.""" status: RefreshRotationStatus family_revoked: bool = False def __post_init__(self) -> None: """Reject success statuses and unproven revocation claims.""" allowed = { RefreshRotationStatus.REPLAY_DETECTED, RefreshRotationStatus.EXPIRED, RefreshRotationStatus.REVOKED, RefreshRotationStatus.EPOCH_MISMATCH, RefreshRotationStatus.INVALID, } if self.status.__class__ is not RefreshRotationStatus or self.status not in allowed: msg = "Refresh preflight outcome requires a negative status" raise ValueError(msg) revoked_status = self.status in {RefreshRotationStatus.REPLAY_DETECTED, RefreshRotationStatus.REVOKED} if self.family_revoked.__class__ is not bool or revoked_status != self.family_revoked: msg = "Refresh preflight revocation status is invalid" raise ValueError(msg)
[docs] @runtime_checkable class RefreshTokenFamilyStore(Protocol): """Atomic strict refresh-family rotation and revocation boundary."""
[docs] async def create_family(self, command: CreateRefreshFamilyCommand, *, event: "SecurityEvent") -> bool: """Create one family only if its account epoch is still current, atomically. Args: command: The family identifier, account binding, epoch, and first token digest. event: The audit event to commit with the family. Rejecting it must fail the creation. Returns: ``True`` when the family was created, ``False`` when the account epoch had already moved on. """ ... # pragma: no cover
[docs] async def prepare_rotation( self, proof: RefreshTokenProof, idempotency_digest: bytes | None, *, now: "datetime", event: "SecurityEvent" ) -> RefreshFamilyContext | RefreshReceiptReplay | RefreshPreflightOutcome: """Atomically return active state, recover a receipt, or revoke and record consumed reuse. This is where reuse detection lives. A token that was already consumed means the token leaked, so the whole family must be revoked in the same operation that observes the reuse. Args: proof: The verified identifier and digest of the presented token. idempotency_digest: The digest of the caller's ``Idempotency-Key``, or ``None`` when the caller sent none. now: The timestamp to evaluate expiry against. event: The audit event to commit with the outcome. Rejecting it must fail the preparation. Returns: The active family context to rotate from, a stored receipt when the caller is retrying with a matching idempotency key, or a result describing why rotation cannot proceed. """ ... # pragma: no cover
[docs] async def rotate( self, command: RotateRefreshCommand, *, now: "datetime", event: "SecurityEvent" ) -> RefreshRotationOutcome: """Atomically revalidate context/current epoch and rotate or revoke. Revalidate rather than trusting the prepared context: the epoch can move between preparation and rotation. Args: command: The family, expected prior token, replacement digest, and receipt to store. now: The commit timestamp. event: The audit event to commit with the rotation. Rejecting it must fail the rotation. Returns: The outcome, distinguishing a committed rotation from a revocation. """ ... # pragma: no cover
[docs] async def revoke_family(self, family_id: str, *, event: "SecurityEvent") -> bool: """Revoke one refresh-token family. Args: family_id: The family to revoke. event: The audit event to commit with the revocation. Rejecting it must fail the revocation. Returns: ``True`` when an active family was revoked. """ ... # pragma: no cover
[docs] async def revoke_token(self, token_id: str, token_digest: bytes, *, event: "SecurityEvent") -> bool: """Revoke the family owning one exact presented token. Args: token_id: The identifier carried by the presented token. token_digest: The digest that must match the stored one. event: The audit event to commit with the revocation. Rejecting it must fail the revocation. Returns: ``True`` when the digest matched and the family was revoked. """ ... # pragma: no cover
[docs] async def revoke_token_for_account( self, account_id: str, token_id: str, token_digest: bytes, *, event: "SecurityEvent" ) -> bool: """Revoke one exact token only when its family belongs to the caller account. Check ownership inside this operation. A caller must not be able to revoke another account's token by presenting its identifier. Args: account_id: The authenticated caller's account. token_id: The identifier carried by the presented token. token_digest: The digest that must match the stored one. event: The audit event to commit with the revocation. Rejecting it must fail the revocation. Returns: ``True`` when the caller owned the family and it was revoked. """ ... # pragma: no cover
[docs] async def revoke_for_account(self, account_id: str, *, event: "SecurityEvent") -> int: """Revoke every refresh family for an account. Args: account_id: The account whose families to revoke. event: The audit event to commit with the revocations. Rejecting it must fail them. Returns: The number of active families revoked. """ ... # pragma: no cover
def _new_refresh_family_id() -> str: return f"{_REFRESH_FAMILY_PREFIX}{encode_random(token_bytes(LOOKUP_BYTES))}" def _new_refresh_event_id() -> str: return f"event_{encode_random(token_bytes(LOOKUP_BYTES))}"
[docs] @dataclass(frozen=True, slots=True) class RefreshTokenService(Generic[UserT]): """Issue, strictly rotate, and revoke opaque local refresh families.""" accounts: object = field(repr=False) store: RefreshTokenFamilyStore = field(repr=False) codec: RefreshTokenCodec = field(repr=False) receipts: RefreshReceiptSealer = field(repr=False) access_tokens: "LocalAccessTokenIssuer[UserT]" = field(repr=False) idle_lifetime: timedelta = _DEFAULT_REFRESH_IDLE_LIFETIME absolute_lifetime: timedelta = _DEFAULT_REFRESH_ABSOLUTE_LIFETIME receipt_window: timedelta = _DEFAULT_REFRESH_RECEIPT_WINDOW clock: Callable[[], datetime] = field(default=utc_now, repr=False, compare=False) family_ids: Callable[[], str] = field(default=_new_refresh_family_id, repr=False, compare=False) event_ids: Callable[[], str] = field(default=_new_refresh_event_id, repr=False, compare=False) rate_limits: RateLimitGuard | None = field(default=None, repr=False, compare=False) def __post_init__(self) -> None: """Validate structural ports, lifetimes, and customization hooks.""" validate_rate_limits(self.rate_limits, name="Refresh token service") accounts_value = object.__getattribute__(self, "accounts") access_tokens_value = object.__getattribute__(self, "access_tokens") if not callable(getattr(accounts_value, "get_by_id", None)) or not callable( getattr(accounts_value, "current_epoch", None) ): msg = "Refresh token accounts must provide account and epoch lookup" raise ImproperlyConfiguredException(detail=msg) if not isinstance(object.__getattribute__(self, "store"), RefreshTokenFamilyStore): msg = "Refresh token store must implement RefreshTokenFamilyStore" raise ImproperlyConfiguredException(detail=msg) if self.codec.__class__ is not RefreshTokenCodec: msg = "Refresh token codec must be RefreshTokenCodec" raise ImproperlyConfiguredException(detail=msg) if self.receipts.__class__ is not RefreshReceiptSealer: msg = "Refresh token receipts must be RefreshReceiptSealer" raise ImproperlyConfiguredException(detail=msg) if not callable(getattr(access_tokens_value, "issue", None)): msg = "Refresh access-token issuer must provide issue()" raise ImproperlyConfiguredException(detail=msg) if ( self.idle_lifetime.__class__ is not timedelta or self.absolute_lifetime.__class__ is not timedelta or self.receipt_window.__class__ is not timedelta or self.idle_lifetime <= timedelta(0) or self.absolute_lifetime < self.idle_lifetime or not timedelta(0) < self.receipt_window <= _MAXIMUM_REFRESH_RECEIPT_WINDOW ): msg = "Refresh token lifetimes are invalid" raise ImproperlyConfiguredException(detail=msg) if not all(callable(value) for value in (self.clock, self.family_ids, self.event_ids)): msg = "Refresh token clock and ID factories must be callable" raise ImproperlyConfiguredException(detail=msg)
[docs] async def issue( # noqa: PLR0911 - preserve explicit sanitized outcomes self, account: "LocalAccountState[UserT]", *, scopes: AbstractSet[str] = frozenset(), evidence: AuthenticationEvidence | None = None, now: datetime | None = None, ) -> TokenPair | InvalidCredentials | VerificationUnavailable: """Create the initial family before revealing either credential. Args: account: The authenticated account to issue for. It must be active and verified. scopes: The scopes to bind into the access token. evidence: Verified authentication assurance to preserve in the initial access token. now: Override the clock, for tests and replayable issuance. Returns: The token pair, ``InvalidCredentials`` when the account may not be issued for, or ``VerificationUnavailable`` when a dependency failed. """ account_value: object = account if ( not isinstance(account_value, LocalAccountState) # pyright: ignore[reportUnnecessaryIsInstance] - defend runtime port boundary or not account_value.active or not account_value.verified ): return InvalidCredentials() try: issued_at = aware_utc_time(self.clock() if now is None else now) current_epoch = await cast("Any", self.accounts).current_epoch(account_value.account_id) except Exception: # noqa: BLE001 - application port failures become one sanitized outcome return VerificationUnavailable() if not valid_security_epoch(current_epoch) or current_epoch != account_value.security_epoch: return InvalidCredentials() normalized_scopes = normalize_refresh_scopes(scopes) if normalized_scopes is None: return InvalidCredentials() try: access: object = await self.access_tokens.issue( account_value, scopes=normalized_scopes, evidence=evidence, now=issued_at ) except Exception: # noqa: BLE001 - application port failures become one sanitized outcome return VerificationUnavailable() if not isinstance(access, LocalAccessToken): return ( access if isinstance( # pyright: ignore[reportUnnecessaryIsInstance] - defend runtime port boundary access, (InvalidCredentials, VerificationUnavailable) ) else VerificationUnavailable() ) try: refresh = self.codec.issue() family_id = self.family_ids() if not valid_identifier(family_id, prefix=_REFRESH_FAMILY_PREFIX): raise ValueError # noqa: TRY301 - customization failure is sanitized below family_expires_at = issued_at + self.absolute_lifetime token_expires_at = min(issued_at + self.idle_lifetime, family_expires_at) command = CreateRefreshFamilyCommand( token_id=refresh.token_id, token_digest=refresh.digest, account_id=account_value.account_id, family_id=family_id, security_epoch=account_value.security_epoch, created_at=issued_at, token_expires_at=token_expires_at, family_expires_at=family_expires_at, scopes=normalized_scopes, evidence=evidence, ) created = await self.store.create_family( command, event=self._event( issued_at, operation=REFRESH_CREATE, outcome=OUTCOME_CREATED, account_id=account_value.account_id, family_id=family_id, ), ) except Exception: # noqa: BLE001 - application port failures become one sanitized outcome return VerificationUnavailable() if created is not True: return VerificationUnavailable() return TokenPair( access_token=access.access_token, refresh_token=refresh.refresh_token, expires_in=access.expires_in )
[docs] async def rotate( # noqa: C901, PLR0911, PLR0912 - security state machine remains explicit self, refresh_token: str, *, idempotency_key: str | None = None, now: datetime | None = None, client_key: str | None = None, ) -> TokenPair | RateLimited | InvalidCredentials | VerificationUnavailable: """Return exactly the store-accepted sealed response or one safe failure. Only the client bucket applies: the presented value is a refresh token, and digesting it into a bucket key would let a limiter backend become a record of which tokens were attempted. Args: refresh_token: The opaque token presented by the client. idempotency_key: Replays a lost response instead of tripping reuse detection, when it matches the key sent with the original request. now: Override the clock, for tests and replayable rotation. client_key: The caller identity for the rate-limit bucket, or ``None`` to skip client-keyed limiting. Returns: The rotated pair, ``RateLimited`` when the budget is spent, ``InvalidCredentials`` when the token is rejected or was reused, or ``VerificationUnavailable`` when a dependency failed. """ if self.rate_limits is not None: limited = await self.rate_limits.check(REFRESH_ROTATE, client_key=client_key) if limited is not None: return limited proof = self.codec.verify(refresh_token) if not isinstance(proof, RefreshTokenProof): return proof idempotency_digest: bytes | None = None invalid_idempotency = False if idempotency_key is not None: digest_result = self.codec.digest_idempotency_key(proof.token_id, idempotency_key) if isinstance(digest_result, InvalidCredentials): invalid_idempotency = True else: idempotency_digest = digest_result try: rotated_at = aware_utc_time(self.clock() if now is None else now) prepared: object = await self.store.prepare_rotation( proof, idempotency_digest, now=rotated_at, event=self._event( rotated_at, operation=REFRESH_PREPARE, outcome=OUTCOME_ATTEMPTED, account_id=None, family_id=None ), ) except Exception: # noqa: BLE001 - application port failures become one sanitized outcome return VerificationUnavailable() if isinstance(prepared, RefreshReceiptReplay): account_result = await self._resolve_account(prepared.context) if not isinstance(account_result, LocalAccountState): return account_result return await self._recover_receipt( prepared.context, prepared.sealed_receipt, token_id=proof.token_id, idempotency_digest=idempotency_digest, occurred_at=rotated_at, ) if isinstance(prepared, RefreshPreflightOutcome): return InvalidCredentials() if not isinstance( # pyright: ignore[reportUnnecessaryIsInstance] - defend runtime port boundary prepared, RefreshFamilyContext ): return VerificationUnavailable() if invalid_idempotency: return InvalidCredentials() if prepared.token_expires_at <= rotated_at or prepared.family_expires_at <= rotated_at: return InvalidCredentials() account_result = await self._resolve_account(prepared) if not isinstance(account_result, LocalAccountState): return account_result account = account_result try: successor = self.codec.issue() access: object = await self.access_tokens.issue( account, scopes=prepared.scopes, evidence=prepared.evidence, now=rotated_at ) except Exception: # noqa: BLE001 - application port failures become one sanitized outcome return VerificationUnavailable() if not isinstance(access, LocalAccessToken): return ( access if isinstance( # pyright: ignore[reportUnnecessaryIsInstance] - defend runtime port boundary access, (InvalidCredentials, VerificationUnavailable) ) else VerificationUnavailable() ) response = TokenPair( access_token=access.access_token, refresh_token=successor.refresh_token, expires_in=access.expires_in ) successor_expires_at = min(rotated_at + self.idle_lifetime, prepared.family_expires_at) receipt_expires_at = min(rotated_at + self.receipt_window, prepared.family_expires_at) context = RefreshReceiptContext( token_id=proof.token_id, family_id=prepared.family_id, account_id=prepared.account_id, security_epoch=prepared.security_epoch, idempotency_digest=idempotency_digest, ) try: sealed_receipt = self.receipts.seal(response, context, expires_at=receipt_expires_at) command = RotateRefreshCommand( token_id=proof.token_id, token_digest=proof.digest, account_id=prepared.account_id, family_id=prepared.family_id, security_epoch=prepared.security_epoch, successor_id=successor.token_id, successor_digest=successor.digest, successor_expires_at=successor_expires_at, family_expires_at=prepared.family_expires_at, sealed_receipt=sealed_receipt, receipt_expires_at=receipt_expires_at, idempotency_digest=idempotency_digest, scopes=prepared.scopes, evidence=prepared.evidence, ) result_value: object = await self.store.rotate( command, now=rotated_at, event=self._event( rotated_at, operation=REFRESH_ROTATE, outcome=OUTCOME_ATTEMPTED, account_id=prepared.account_id, family_id=prepared.family_id, ), ) except Exception: # noqa: BLE001 - application port failures become one sanitized outcome return VerificationUnavailable() if not isinstance(result_value, RefreshRotationOutcome): # pyright: ignore[reportUnnecessaryIsInstance] - defend runtime port boundary return VerificationUnavailable() result = result_value if result.status not in {RefreshRotationStatus.ROTATED, RefreshRotationStatus.IDEMPOTENT_REPLAY}: return InvalidCredentials() return await self._recover_receipt( prepared, cast("bytes", result.sealed_receipt), token_id=proof.token_id, idempotency_digest=idempotency_digest, occurred_at=rotated_at, )
[docs] async def revoke( self, refresh_token: str, *, now: datetime | None = None ) -> bool | InvalidCredentials | VerificationUnavailable: """Revoke the family owning one exact presented opaque token. Args: refresh_token: The opaque token whose family to revoke. now: Override the clock, for tests and replayable revocation. Returns: Whether an active family was revoked, ``InvalidCredentials`` when the token is rejected, or ``VerificationUnavailable`` when the store failed. """ proof = self.codec.verify(refresh_token) if not isinstance(proof, RefreshTokenProof): return proof try: occurred_at = aware_utc_time(self.clock() if now is None else now) revoked = await self.store.revoke_token( proof.token_id, proof.digest, event=self._event( occurred_at, operation=REFRESH_REVOKE, outcome=OUTCOME_REVOKED, account_id=None, family_id=None ), ) except Exception: # noqa: BLE001 - application port failures become one sanitized outcome return VerificationUnavailable() return revoked if revoked.__class__ is bool else VerificationUnavailable()
[docs] async def revoke_for_account( self, account_id: str, refresh_token: str, *, now: datetime | None = None ) -> bool | InvalidCredentials | VerificationUnavailable: """Revoke one caller-owned refresh family without exposing cross-account state. Args: account_id: The authenticated caller's account. refresh_token: The opaque token whose family to revoke. now: Override the clock, for tests and replayable revocation. Returns: Whether an active family was revoked, ``InvalidCredentials`` when the token is rejected, or ``VerificationUnavailable`` when the store failed. A token owned by another account is reported as not revoked rather than as a distinct failure. """ proof = self.codec.verify(refresh_token) if not strict_context_text(account_id) or not isinstance(proof, RefreshTokenProof): return InvalidCredentials() try: occurred_at = aware_utc_time(self.clock() if now is None else now) revoked = await self.store.revoke_token_for_account( account_id, proof.token_id, proof.digest, event=self._event( occurred_at, operation=REFRESH_REVOKE, outcome=OUTCOME_REVOKED, account_id=account_id, family_id=None, ), ) except Exception: # noqa: BLE001 - application port failures become one sanitized outcome return VerificationUnavailable() return revoked if revoked.__class__ is bool else VerificationUnavailable()
async def _resolve_account( self, context: RefreshFamilyContext ) -> "LocalAccountState[UserT] | InvalidCredentials | VerificationUnavailable": try: account = await cast("Any", self.accounts).get_by_id(context.account_id) current_epoch = await cast("Any", self.accounts).current_epoch(context.account_id) except Exception: # noqa: BLE001 - application port failures become one sanitized outcome return VerificationUnavailable() if ( not isinstance(account, LocalAccountState) or account.account_id != context.account_id or not account.active or not account.verified or account.security_epoch != context.security_epoch or not valid_security_epoch(current_epoch) or current_epoch != context.security_epoch ): return InvalidCredentials() return cast("LocalAccountState[UserT]", account) async def _fail_closed_receipt( self, context: RefreshFamilyContext, occurred_at: datetime ) -> InvalidCredentials | VerificationUnavailable: try: revoked = await self.store.revoke_family( context.family_id, event=self._event( occurred_at, operation=REFRESH_RECEIPT, outcome=OUTCOME_REVOKED, account_id=context.account_id, family_id=context.family_id, ), ) except Exception: # noqa: BLE001 - application port failures become one sanitized outcome return VerificationUnavailable() return InvalidCredentials() if revoked is True else VerificationUnavailable() async def _recover_receipt( self, context: RefreshFamilyContext, sealed_receipt: bytes, *, token_id: str, idempotency_digest: bytes | None, occurred_at: datetime, ) -> TokenPair | InvalidCredentials | VerificationUnavailable: receipt_context = RefreshReceiptContext( token_id=token_id, family_id=context.family_id, account_id=context.account_id, security_epoch=context.security_epoch, idempotency_digest=idempotency_digest, ) accepted = self.receipts.unseal(sealed_receipt, receipt_context, now=occurred_at) return accepted if isinstance(accepted, TokenPair) else await self._fail_closed_receipt(context, occurred_at) def _event( self, occurred_at: datetime, *, operation: str, outcome: str, account_id: str | None, family_id: str | None ) -> "SecurityEvent": event_id = self.event_ids() if not strict_context_text(event_id): raise ValueError return SecurityEvent( event_id=event_id, occurred_at=occurred_at, operation=operation, outcome=outcome, account_id=account_id, family_id=family_id, mechanism="refresh", )