"""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]