Source code for open_atp.auth
"""What each prover authenticates with, and for how long it stays valid.
Every prover needs a credential before it can run, but they don't take the same
shape: some read an API key from the environment, others carry an OAuth token
minted by a ``login`` command and stored on disk. This module is the vocabulary
for reporting on either -- :class:`AuthStatus` is what a prover (or its harness)
returns when asked *"can you run right now, and for how much longer?"*, and the
``auth-status`` CLI command tabulates one per standard prover.
An OAuth credential that ships a refresh token renews itself on the host whenever
its CLI runs. That renewal does **not** happen inside a sandbox: the credential is
copied in, so a token refreshed there is discarded with the container while the
host copy stays stale. An expired-but-refreshable credential therefore still needs
a host-side refresh (run the CLI, or log in again) before a run will authenticate.
"""
from __future__ import annotations
import enum
import os
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
#: Threshold until expiration for :attr:`AuthState.EXPIRING` status.
EXPIRY_WARNING_THRESHOLD = timedelta(minutes=15)
[docs]
class AuthKind(enum.Enum):
"""The kind of credential a prover authenticates with."""
#: A provider API key.
API_KEY = "api_key"
#: An OAuth token minted by a ``login`` command.
OAUTH = "oauth"
[docs]
class AuthState(enum.Enum):
"""Whether a credential is usable, and if not, why."""
#: Present and valid.
OK = "ok"
#: Present and valid, but for less than :data:`EXPIRY_WARNING_THRESHOLD`.
EXPIRING = "expiring"
#: Present, but its validity window has passed.
EXPIRED = "expired"
#: Not found in the environment or on disk.
MISSING = "missing"
[docs]
@dataclass(frozen=True)
class AuthStatus:
"""The state of a prover's credential, as read from the host.
Reported by :meth:`~open_atp.provers.base.AutomatedProver.auth_status`. Reading
the host never validates the credential against its provider; a present,
unexpired credential can still be revoked or simply wrong.
Parameters
----------
kind : AuthKind
Whether the prover authenticates with an API key or an OAuth token.
source : str
Where the credential is read from: an environment variable name, or the
path of the CLI credential file.
present : bool
Whether the credential was found at ``source``.
expires_at : datetime.datetime, optional
When the credential stops being valid. ``None`` (the default) when it does
not expire or exposes no expiry.
refreshable : bool, default False
Whether the credential ships a refresh token, letting its CLI renew it on
the host. Sandboxed runs cannot refresh it themselves.
remedy : str, optional
How to obtain the credential (e.g., command or web page).
Defaults to :attr:`source`.
"""
kind: AuthKind
source: str
present: bool
expires_at: datetime | None = None
refreshable: bool = False
remedy: str = ""
[docs]
@classmethod
def from_env(
cls,
env_name: str,
explicit: str | None = None,
kind: AuthKind = AuthKind.API_KEY,
remedy: str = "",
) -> AuthStatus:
"""Read a credential held in an environment variable.
Parameters
----------
env_name : str
The environment variable holding the credential.
explicit : str or None, optional
A credential passed directly, taking precedence over the environment.
kind : AuthKind, default AuthKind.API_KEY
What the credential type is.
remedy : str, optional
How to obtain the credential.
Returns
-------
AuthStatus
A status that never expires; an environment variable carries no expiry.
Examples
--------
An unset variable reads as missing:
>>> from open_atp.auth import AuthStatus
>>> AuthStatus.from_env("NOT_A_REAL_API_KEY").state()
<AuthState.MISSING: 'missing'>
"""
return cls(
kind=kind,
source=env_name,
present=bool(explicit or os.environ.get(env_name)),
remedy=remedy,
)
[docs]
def time_remaining(self, now: datetime | None = None) -> timedelta | None:
"""How long the credential stays valid, relative to ``now``.
Parameters
----------
now : datetime.datetime, optional
The instant to measure from; defaults to the current UTC time.
Returns
-------
datetime.timedelta or None
Time until expiry, negative once past it. ``None`` when the credential
is absent or exposes no expiry.
"""
if not self.present or self.expires_at is None:
return None
return self.expires_at - (now or datetime.now(UTC))
[docs]
def state(self, now: datetime | None = None) -> AuthState:
"""Classify the credential as of ``now``.
Parameters
----------
now : datetime.datetime, optional
The instant to classify against; defaults to the current UTC time.
Returns
-------
AuthState
:attr:`~AuthState.MISSING` if absent, else the position of ``now`` in
the validity window. A credential with no expiry is always
:attr:`~AuthState.OK` when present.
Examples
--------
A key read from the environment neither expires nor refreshes:
>>> from open_atp.auth import AuthKind, AuthStatus
>>> status = AuthStatus(AuthKind.API_KEY, "OPENAI_API_KEY", present=True)
>>> status.state()
<AuthState.OK: 'ok'>
"""
if not self.present:
return AuthState.MISSING
remaining = self.time_remaining(now)
if remaining is None:
return AuthState.OK
if remaining <= timedelta(0):
return AuthState.EXPIRED
return (
AuthState.EXPIRING if remaining < EXPIRY_WARNING_THRESHOLD else AuthState.OK
)