Source code for iiisight.auth

"""IIIF Authorization Flow 2.0 (metadata + non-interactive token exchange).

iiisight *describes* the auth services on a resource and can drive the
non-interactive parts of the flow — probing whether a resource is accessible and
exchanging for an access token where the token service is directly callable
(e.g. an ``external`` profile relying on cookies). The **interactive** login step
(opening the access service in a browser and reading the token via postMessage)
is inherently the host application's job and is out of scope; iiisight gives you
the service metadata to drive it.

The auth services are a nested tree (``AuthProbeService2`` → ``AuthAccessService2``
→ ``AuthAccessTokenService2`` / ``AuthLogoutService2``) carrying fields outside the
normalized subset, so they are parsed from each :class:`~iiisight.model.Service`'s
preserved ``raw`` JSON.
"""

from collections.abc import Mapping
from dataclasses import dataclass
from typing import Any

from .language import LanguageMap
from .model import Canvas, ContentResource, Described, Service

PROBE_TYPE = "AuthProbeService2"
ACCESS_TYPE = "AuthAccessService2"
TOKEN_TYPE = "AuthAccessTokenService2"
LOGOUT_TYPE = "AuthLogoutService2"


[docs] @dataclass(frozen=True) class LogoutService: """An ``AuthLogoutService2``.""" id: str label: LanguageMap | None = None
[docs] @dataclass(frozen=True) class AccessTokenService: """An ``AuthAccessTokenService2`` — where a token is obtained.""" id: str error_heading: LanguageMap | None = None error_note: LanguageMap | None = None
[docs] @dataclass(frozen=True) class AccessService: """An ``AuthAccessService2`` — where the user authenticates.""" id: str profile: str | None = None label: LanguageMap | None = None heading: LanguageMap | None = None note: LanguageMap | None = None confirm_label: LanguageMap | None = None token_service: AccessTokenService | None = None logout_service: LogoutService | None = None
[docs] @dataclass(frozen=True) class ProbeService: """An ``AuthProbeService2`` — the entry point to a resource's auth flow.""" id: str access_services: tuple[AccessService, ...] = () @property def token_service(self) -> AccessTokenService | None: """The first access token service among the access services, if any.""" for access in self.access_services: if access.token_service is not None: return access.token_service return None
[docs] @dataclass(frozen=True) class ProbeResult: """The result of calling a probe service (``AuthProbeResult2``).""" status: int location: str | None = None heading: LanguageMap | None = None note: LanguageMap | None = None @property def accessible(self) -> bool: """True when the resource is accessible with the credentials supplied.""" return self.status == 200
[docs] @dataclass(frozen=True) class TokenResult: """The result of a non-interactive token exchange.""" access_token: str | None = None expires_in: int | None = None error: str | None = None @property def ok(self) -> bool: return self.access_token is not None
[docs] def find_probe_service(resource: Described | ContentResource | Service) -> ProbeService | None: """Find the auth probe service on a resource, if it is access-controlled. Accepts a :class:`Canvas` (searches its images' services), a :class:`ContentResource`, a bare :class:`Service`, or any resource with a ``services`` list. """ for service in _candidate_services(resource): probe = probe_service_from(service) if probe is not None: return probe return None
[docs] def probe_service_from(service: Service) -> ProbeService | None: """Parse a :class:`Service` into a :class:`ProbeService`, or ``None``.""" raw = service.raw or {} if (service.type or raw.get("type")) != PROBE_TYPE: return None access = tuple( _access_service(entry) for entry in _as_list(raw.get("service")) if isinstance(entry, Mapping) and entry.get("type") == ACCESS_TYPE ) return ProbeService(id=service.id, access_services=access)
[docs] def parse_probe_result(document: Mapping[str, Any]) -> ProbeResult: """Parse an ``AuthProbeResult2`` document.""" location = document.get("location") if isinstance(location, Mapping): location_id = location.get("id") or location.get("@id") elif isinstance(location, str): location_id = location else: location_id = None return ProbeResult( status=int(document.get("status", 200)), location=str(location_id) if location_id else None, heading=_lang(document.get("heading")), note=_lang(document.get("note")), )
[docs] def parse_token_result(document: Mapping[str, Any]) -> TokenResult: """Parse an ``AuthAccessToken2`` (or error) document.""" token = document.get("accessToken") if token: return TokenResult(access_token=str(token), expires_in=document.get("expiresIn")) error = document.get("profile") or document.get("error") or document.get("type") return TokenResult(error=str(error) if error else "unknown")
def _candidate_services(resource: Described | ContentResource | Service) -> list[Service]: if isinstance(resource, Service): return [resource] if isinstance(resource, ContentResource): return list(resource.services) if isinstance(resource, Canvas): collected: list[Service] = [] for image in resource.images: collected.extend(image.services) collected.extend(resource.services) return collected return list(getattr(resource, "services", ()) or ()) def _access_service(raw: Mapping[str, Any]) -> AccessService: token_service: AccessTokenService | None = None logout_service: LogoutService | None = None for entry in _as_list(raw.get("service")): if not isinstance(entry, Mapping): continue if entry.get("type") == TOKEN_TYPE: token_service = AccessTokenService( id=str(entry.get("id", "")), error_heading=_lang(entry.get("errorHeading")), error_note=_lang(entry.get("errorNote")), ) elif entry.get("type") == LOGOUT_TYPE: logout_service = LogoutService( id=str(entry.get("id", "")), label=_lang(entry.get("label")) ) return AccessService( id=str(raw.get("id", "")), profile=raw.get("profile"), label=_lang(raw.get("label")), heading=_lang(raw.get("heading")), note=_lang(raw.get("note")), confirm_label=_lang(raw.get("confirmLabel")), token_service=token_service, logout_service=logout_service, ) def _lang(value: Any) -> LanguageMap | None: if isinstance(value, Mapping): return LanguageMap( {key: val if isinstance(val, list) else [val] for key, val in value.items()} ) if isinstance(value, str): return LanguageMap({"none": [value]}) return None def _as_list(value: Any) -> list[Any]: if value is None: return [] if isinstance(value, list): return value return [value]