Skip to content

Auth

pytruenas.auth

__all__ = ['Credentials', 'LocalAuth', 'APIKeyAuth', 'TokenAuth', 'BasicAuth', 'redact_call_args'] module-attribute

APIKeyAuth(api_key, username=None)

Bases: Credentials

Source code in src/pytruenas/auth.py
350
351
352
353
354
355
def __init__(self, api_key: str, username: _ty.Optional[str] = None) -> None:
    self.api_key = api_key
    # login_ex's API_KEY_PLAIN wants the username too; the legacy
    # login_with_api_key does not. If unknown, the modern path can't be used
    # and login_ex falls back to legacy.
    self.username = username

MECHANISM = 'API_KEY_PLAIN' class-attribute instance-attribute

METHOD = 'login_with_api_key' class-attribute instance-attribute

api_key = api_key instance-attribute

username = username instance-attribute

BasicAuth(username, password, otp=None)

Bases: Credentials

Source code in src/pytruenas/auth.py
388
389
390
391
392
393
def __init__(
    self, username: str, password: str, otp: _ty.Optional[str] = None
) -> None:
    self.username = username
    self.password = password
    self.otp = otp

MECHANISM = 'PASSWORD_PLAIN' class-attribute instance-attribute

METHOD = 'login' class-attribute instance-attribute

otp = otp instance-attribute

password = password instance-attribute

username = username instance-attribute

Credentials(*args, **kwargs)

Source code in src/pytruenas/auth.py
181
182
def __init__(self, *args, **kwargs) -> None:
    pass

MECHANISM = None class-attribute instance-attribute

METHOD = None class-attribute instance-attribute

from_env(env=None) staticmethod

The credentials in $PYTRUENAS_CREDENTIALS (a secret).

Any form Credentials(...) takes as one string; unset or empty is :class:LocalAuth. env is a mapping to read the variable from in place of the process environment.

Source code in src/pytruenas/auth.py
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
@staticmethod
def from_env(env: _ty.Optional[_ty.Mapping] = None) -> "Credentials":
    """The credentials in ``$PYTRUENAS_CREDENTIALS`` (a secret).

    Any form ``Credentials(...)`` takes as one string; unset or empty is
    :class:`LocalAuth`. ``env`` is a mapping to read the variable from in
    place of the process environment.
    """
    if env is None:
        from ._utils.env import ENV

        raw = ENV.get("CREDENTIALS")
    else:
        raw = env.get("PYTRUENAS_CREDENTIALS")
    return Credentials(raw or None)

from_host_credentials(username=None, password=None, otp=None, api_key=None, token=None) staticmethod

Select a credential from an already-parsed credential mapping.

This is the hostctl bridge. hostctl.host.parse_credentials has already split a URI's password field into the password proper and its trailing key:value extras -- the OTP arrives here as otp, not embedded in password. So this method deliberately does no string parsing: it only picks the matching subclass. Contrast Credentials(...), which accepts the raw single-string form used by $PYTRUENAS_CREDENTIALS and credentials=.

Both projects encode an OTP the same way -- a newline after the password, then otp:<token> -- so the two paths agree on meaning while splitting the string exactly once, here or in hostctl, never twice.

A newline is the separator because a password can never contain one: at an interactive prompt, Enter submits the value rather than being typed into it. That makes the split free -- no character is stolen from the password alphabet and no escaping scheme is needed. The one caveat is transport-specific: a URI cannot carry a raw newline (urlsplit strips it), so an OTP in a connection string must be percent-encoded as %0A. See :mod:pytruenas.host.

Precedence when several are supplied: token, then api_key, then password. They are mutually exclusive mechanisms rather than a fallback chain, so passing more than one is a caller error; the order only makes the outcome deterministic rather than arbitrary. With none of them, this is local-socket auth (:class:LocalAuth), which is also what an empty mapping means for a local target.

Source code in src/pytruenas/auth.py
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
@staticmethod
def from_host_credentials(
    username: "_ty.Optional[str]" = None,
    password: "_ty.Optional[str]" = None,
    otp: "_ty.Optional[str]" = None,
    api_key: "_ty.Optional[str]" = None,
    token: "_ty.Optional[str]" = None,
) -> "Credentials":
    """Select a credential from an already-parsed credential mapping.

    This is the ``hostctl`` bridge. ``hostctl.host.parse_credentials`` has
    already split a URI's password field into the password proper and its
    trailing ``key:value`` extras -- the OTP arrives here as ``otp``, not
    embedded in ``password``. So this method deliberately does **no string
    parsing**: it only picks the matching subclass. Contrast
    ``Credentials(...)``, which accepts the raw single-string form used by
    ``$PYTRUENAS_CREDENTIALS`` and ``credentials=``.

    Both projects encode an OTP the same way -- a newline after the
    password, then ``otp:<token>`` -- so the two paths agree on meaning
    while splitting the string exactly once, here or in hostctl, never
    twice.

    A newline is the separator because **a password can never contain one**:
    at an interactive prompt, Enter submits the value rather than being
    typed into it. That makes the split free -- no character is stolen from
    the password alphabet and no escaping scheme is needed. The one caveat
    is transport-specific: a *URI* cannot carry a raw newline (``urlsplit``
    strips it), so an OTP in a connection string must be percent-encoded as
    ``%0A``. See :mod:`pytruenas.host`.

    Precedence when several are supplied: ``token``, then ``api_key``, then
    ``password``. They are mutually exclusive mechanisms rather than a
    fallback chain, so passing more than one is a caller error; the order
    only makes the outcome deterministic rather than arbitrary. With none of
    them, this is local-socket auth (:class:`LocalAuth`), which is also what
    an empty mapping means for a local target.
    """
    if token:
        return TokenAuth(token)
    if api_key:
        return APIKeyAuth(api_key, username)
    if password:
        # username defaults to root to match the rest of the stack: hostctl's
        # SSHConfig and pytruenas' own shell handling both treat a missing
        # user as root, and BasicAuth requires one positionally.
        return BasicAuth(username or "root", password, otp)
    # An OTP with nothing to attach it to cannot authenticate anything, and
    # silently downgrading to LocalAuth would turn a typo into a confusing
    # "local socket unavailable" much later. Fail where the mistake is.
    if otp:
        raise ValueError("otp requires a password")
    return LocalAuth()

login(client)

Legacy login via auth.login/login_with_*.

Those methods answer a refusal with False, not an error (measured on 26.0: a wrong API key or password returned False and left the session unauthenticated), so False raises :class:AuthenticationError here.

Source code in src/pytruenas/auth.py
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
def login(self, client: "TrueNASClient"):
    """Legacy login via ``auth.login``/``login_with_*``.

    Those methods answer a refusal with ``False``, not an error (measured
    on 26.0: a wrong API key or password returned ``False`` and left the
    session unauthenticated), so ``False`` raises
    :class:`AuthenticationError` here.
    """
    if not self.METHOD:
        return None
    ok = client.api.auth[self.METHOD](*self._args())
    if ok is False:
        raise AuthenticationError(
            "AUTH_ERR", {"method": self.METHOD}, method=self.METHOD
        )
    return ok

login_ex(client, *, login_options=None, otp_provider=None)

Authenticate via the modern auth.login_ex mechanism.

Handles the discriminated response: SUCCESS returns the response dict; OTP_REQUIRED calls auth.login_ex_continue with an OTP token (from this credential's own otp if set, else from otp_provider() if given, else raises); any other response_type raises :class:AuthenticationError. A credential with no login_ex mechanism (e.g. :class:LocalAuth) falls back to legacy :meth:login.

login_options overrides the server defaults ({"user_info": True, "reconnect_token": False}).

Source code in src/pytruenas/auth.py
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
def login_ex(
    self,
    client: "TrueNASClient",
    *,
    login_options: "_ty.Optional[dict]" = None,
    otp_provider: "_ty.Optional[_ty.Callable[[], str]]" = None,
) -> "_ty.Optional[dict]":
    """Authenticate via the modern ``auth.login_ex`` mechanism.

    Handles the discriminated response: ``SUCCESS`` returns the response
    dict; ``OTP_REQUIRED`` calls ``auth.login_ex_continue`` with an OTP
    token (from this credential's own ``otp`` if set, else from
    ``otp_provider()`` if given, else raises); any other ``response_type``
    raises :class:`AuthenticationError`. A credential with no login_ex
    mechanism (e.g. :class:`LocalAuth`) falls back to legacy :meth:`login`.

    ``login_options`` overrides the server defaults (``{"user_info": True,
    "reconnect_token": False}``).
    """
    data = self._login_data()
    if data is None:  # no login_ex form -> legacy path
        self.login(client)
        return None
    if login_options is not None:
        data = {**data, "login_options": login_options}

    resp = client.api.auth.login_ex(data)
    resp = _ty.cast(dict, resp) or {}
    rtype = resp.get("response_type")

    if rtype == "OTP_REQUIRED":
        otp = getattr(self, "otp", None)
        if not otp and otp_provider is not None:
            otp = otp_provider()
        if not otp:
            raise AuthenticationError("OTP_REQUIRED", resp)
        resp = (
            _ty.cast(
                dict,
                client.api.auth.login_ex_continue(
                    {"mechanism": "OTP_TOKEN", "otp_token": otp}
                ),
            )
            or {}
        )
        rtype = resp.get("response_type")

    if rtype == "SUCCESS":
        return resp
    raise AuthenticationError(rtype or "UNKNOWN", resp)

LocalAuth()

Bases: Credentials

Local unix-socket auth: no login call, and no credential to carry.

Source code in src/pytruenas/auth.py
336
337
338
339
340
def __init__(self) -> None:
    # Explicitly argument-less: inheriting Credentials' permissive
    # __init__(*args, **kwargs) made this the catch-all that swallowed
    # every keyword the factory tried on it.
    pass

TokenAuth(token)

Bases: Credentials

Source code in src/pytruenas/auth.py
374
375
def __init__(self, token: str) -> None:
    self.token = token

MECHANISM = 'TOKEN_PLAIN' class-attribute instance-attribute

METHOD = 'login_with_token' class-attribute instance-attribute

token = token instance-attribute

redact_call_args(method, args)

args of an RPC call, safe to log.

auth.* methods take credentials positionally (auth.login(user, password), login_with_api_key(key)), so their arguments are reduced to type names. Elsewhere, a dict value under a secret-looking key (password, *_token, private_key, …) is masked at any depth -- which covers the SSH private key install_sshcreds uploads.

Source code in src/pytruenas/auth.py
52
53
54
55
56
57
58
59
60
61
62
63
def redact_call_args(method: str, args: tuple) -> tuple:
    """``args`` of an RPC call, safe to log.

    ``auth.*`` methods take credentials positionally (``auth.login(user,
    password)``, ``login_with_api_key(key)``), so their arguments are reduced to
    type names. Elsewhere, a dict value under a secret-looking key (``password``,
    ``*_token``, ``private_key``, …) is masked at any depth -- which covers the
    SSH private key ``install_sshcreds`` uploads.
    """
    if method.split(".", 1)[0] == "auth":
        return _mask_args(args)
    return _ty.cast(tuple, _redact(args))