diff --git a/docs/local/oidc_credentials_backend_adapter.md b/docs/local/oidc_credentials_backend_adapter.md new file mode 100644 index 000000000..11551789a --- /dev/null +++ b/docs/local/oidc_credentials_backend_adapter.md @@ -0,0 +1,184 @@ +# OIDC credential storage: pluggable backend adapter + +Design note for **validmind-library** OIDC device-flow authentication. Describes the pluggable storage adapter so self-hosted and enterprise deployments can integrate approved secret stores (keychain, Vault, etc.) when local file caching does not meet their security requirements. + +**Related code (validmind-library):** + +- `validmind/credentials_store.py` — default `~/.validmind/credentials.json` persistence +- `validmind/oidc_device.py` — RFC 8628 device flow and token refresh +- `validmind/api_client.py` — `vm.init()` OIDC branch and `_obtain_oidc_tokens()` + +**Related code (backend):** + +- `src/backend/auth/auth.py` — `requires_client_auth` (library + public API Bearer vs API key) +- `src/backend/auth/oidc.py` — server-side userinfo cache in Redis (not client token persistence) + +--- + +## Motivation + +OIDC device flow (`vm.init(issuer=..., client_id=...)`) caches tokens locally for a smooth notebook experience. The default location is: + +``` +~/.validmind/credentials.json +``` + +That default uses restrictive POSIX permissions (`0700` directory, `0600` file, atomic write) and works well for single-user development. Some enterprise and self-hosted deployments need stronger options: + +- Tokens stored in an **OS keychain** or **corporate secret manager** instead of a local JSON file +- **Memory-only** sessions on shared Jupyter or VM hosts (re-auth each process) +- **Custom persistence** aligned with internal security policies + +This adapter is an **optional enterprise security enhancement**. The default file cache remains unchanged for users who do not configure a backend. + +This applies to the **library client** only; the ValidMind backend API server does not persist client access tokens to disk. + +--- + +## Current architecture + +### Backend (server) + +- Validates `Authorization: Bearer` JWTs on each request via `verify_auth_token`. +- Does **not** persist client access tokens to disk. +- Caches OIDC **userinfo** in Redis with a SHA-256 hash of the token as the cache key (TTL default 900s). Falls back to in-process LRU if Redis is unavailable. +- Library and public API connectivity policy: `api_key` | `device_flow` | `any` (vmconfig + per-org overrides). + +### validmind-library (client) + +1. `vm.init(..., issuer=, client_id=, ...)` calls `_obtain_oidc_tokens()`. +2. **Load** cached entry for `(issuer, client_id[, audience])`. +3. Reuse if not expired; **refresh** if refresh token present; otherwise run **device flow**. +4. **Persist** tokens via `credentials_store` (filesystem by default). +5. Send `Authorization: Bearer` on tracking API requests. + +Public API sample script (`backend/scripts/sample_public_api_with_device_flow.py`) does not persist tokens — in-memory only for that run. + +--- + +## Is filesystem caching standard? + +**Common for developer CLIs/SDKs** (AWS CLI, Azure CLI, gcloud) and suitable as a default for local development. + +Deployments with stricter policies often prefer the **OS credential store** (macOS Keychain, Windows Credential Manager, Linux Secret Service) or a **corporate secret manager**. The pluggable backend lets those deployments opt in without changing the default for everyone else. + +--- + +## Proposed solution: `OidcCredentialsBackend` adapter + +Provide a small **storage-only** interface that customers implement. The library retains ownership of OIDC discovery, device flow, refresh logic, and Bearer header selection. + +### Principle + +| Layer | Owner | Pluggable? | +|-------|-------|------------| +| Device flow, refresh, JWT usage | validmind-library | No | +| Token persistence (get / put / delete) | Customer or default file backend | **Yes** | + +### Protocol (conceptual) + +```python +from typing import Protocol, runtime_checkable + +@runtime_checkable +class OidcCredentialsBackend(Protocol): + """Persist OIDC token entries keyed by credential_key(issuer, client_id, audience).""" + + def get(self, key: str) -> dict | None: ... + def put(self, key: str, entry: dict) -> None: ... + def delete(self, key: str) -> None: ... +``` + +**Entry dict contract** (unchanged from today): + +| Field | Required | Notes | +|-------|----------|-------| +| `issuer`, `client_id` | yes | normalized | +| `access_token` | yes | | +| `expires_at` | yes | ISO-8601 UTC | +| `refresh_token` | no | enables silent refresh | +| `id_token` | no | Entra path may use this as Bearer | +| `audience` | no | when configured | + +Public helpers remain shared utilities: `normalize_issuer`, `normalize_client_id`, `normalize_audience`, `credential_key`, `is_expired`. + +### Built-in backends (ship in core) + +| Backend | Purpose | +|---------|---------| +| `FileCredentialsBackend` | Current `~/.validmind/credentials.json` behavior — **default** | +| `MemoryCredentialsBackend` | No disk persistence; re-auth each process — shared Jupyter / CI | +| `NullCredentialsBackend` | Explicit alias for “never persist” | + +Optional first-party extras (`validmind[keyring]`, etc.) can follow later; not required for the adapter story. + +### Registration options + +**1. Explicit (notebook / script)** + +```python +import validmind as vm +from acme.validmind_credentials import VaultOidcBackend + +vm.init( + issuer="...", + client_id="...", + model="...", + credentials_backend=VaultOidcBackend(vault_path="..."), +) +``` + +**2. Environment / platform injection (JupyterHub, VM image)** + +```bash +export VM_OIDC_CREDENTIALS_BACKEND="acme.validmind_credentials:VaultOidcBackend" +export VM_OIDC_CREDENTIALS_BACKEND_KWARGS='{"vault_path":"..."}' +``` + +**3. IPython / site startup hook** + +```python +import validmind.credentials_backend as cb +cb.set_default_backend(VaultOidcBackend(...)) +``` + +Env-based registration is important for self-hosted Jupyter where end users do not write adapter code. + +--- + +## Design decisions to nail down + +- **Thread safety** — Backends may be called from sync paths and future async refresh; document process-safe requirements. +- **Multi-tenant Jupyter** — Allow backends to scope storage by `$JUPYTERHUB_USER`, org id, etc. (constructor kwargs from env or context dict). +- **Errors** — `get` returns `None` when missing; storage failures raise `ValidMindAuthError` (do not silently fall back to device flow on Vault outage unless configured). +- **Logout / clear** — Expose `vm.clear_oidc_credentials()` delegating to `backend.delete(key)`. +- **Migration** — Optional one-time read from file backend and re-put to custom backend when switching deployments. + +--- + +## Suggested implementation rollout (validmind-library) + +1. Extract `FileCredentialsBackend` from `credentials_store.py` (no behavior change). +2. Add `MemoryCredentialsBackend` and env-based backend loading. +3. Thread `credentials_backend` through `init()` and `_obtain_oidc_tokens()`. +4. Document the protocol and a minimal reference adapter (e.g. env-var-only tokens for CI). +5. Update `docs/oidc-device-flow-release-notes.md` with enterprise / adapter section. +6. Optionally publish reference adapters (Keychain, Vault) in a separate template or extras package. + +--- + +## Customer-facing summary (security questionnaire) + +> **Default:** OIDC access and refresh tokens are stored locally on the analyst machine at `~/.validmind/credentials.json` with user-only file permissions. Tokens are not stored on ValidMind servers. This default is intended for individual development and notebook use. +> +> **Enterprise (optional):** Organizations with stricter credential policies can use a pluggable **`OidcCredentialsBackend`** to persist tokens in an approved system (OS keychain, HashiCorp Vault, AWS Secrets Manager, etc.). The SDK continues to handle OIDC device flow and token refresh; only persistence is delegated to your adapter. Set `VM_OIDC_NO_PERSIST=1` for memory-only storage on shared hosts. +> +> **Alternative:** Use API key authentication (`VM_API_KEY` / `VM_API_SECRET`) with secrets managed by your existing tooling — already supported and often preferred in high-assurance environments. + +--- + +## Out of scope (for this adapter) + +- Pluggable OAuth grant types (authorization code, client credentials) — device flow only today. +- Backend server changes — server already does not write client tokens to disk. +- Shipping every secret-manager integration inside core `validmind` — customer-owned adapters instead. diff --git a/docs/oidc-device-flow-release-notes.md b/docs/oidc-device-flow-release-notes.md index f420b70f0..77de23a5b 100644 --- a/docs/oidc-device-flow-release-notes.md +++ b/docs/oidc-device-flow-release-notes.md @@ -79,7 +79,10 @@ api_client.init(issuer="...", client_id="...", model="...", api_host="...") ## 3. How the flow works (overview) -1. **`vm.init(..., issuer=, client_id=, ...)`** loads cached tokens from `~/.validmind/credentials.json` (if present) for the normalized `(issuer, client_id)` — and optional audience — key. +1. **`vm.init(..., issuer=, client_id=, ...)`** loads cached tokens from the active + :class:`~validmind.credentials_backend.OidcCredentialsBackend` (default: + `~/.validmind/credentials.json`) for the normalized `(issuer, client_id)` — and + optional audience — key. 2. If there is a **valid access token**, it is reused. 3. If the access token is expired but a **refresh token** is available, the library **refreshes** silently; on failure it clears that cache entry and continues. 4. If no usable token exists, the library runs the **device authorization flow**: @@ -103,6 +106,6 @@ Org and model access are enforced server-side: the user must be allowed to use t | API host / URL | Tracking API base (`api_host`, `api_url`, or `VM_API_HOST`) | | Scope | OAuth scopes (default `openid profile email`) | | Audience | Often needed so access tokens target the ValidMind API (`audience` or `VM_OIDC_AUDIENCE`) | -| Credential file | `~/.validmind/credentials.json` (cached tokens; restrict like other secrets on shared machines) | +| Credential file | `~/.validmind/credentials.json` by default; override with `credentials_backend`, `VM_OIDC_CREDENTIALS_BACKEND`, or `VM_OIDC_NO_PERSIST=1` | -For implementation details in this repository, see `validmind/oidc_device.py`, `validmind/credentials_store.py`, and `validmind/api_client.py`. +For implementation details in this repository, see `validmind/oidc_device.py`, `validmind/credentials_backend.py`, `validmind/credentials_store.py`, and `validmind/api_client.py`. diff --git a/tests/test_api_client.py b/tests/test_api_client.py index 072546beb..9d67c3283 100644 --- a/tests/test_api_client.py +++ b/tests/test_api_client.py @@ -556,12 +556,11 @@ def test_init_oidc_uses_env_config(self, mock_obtain, mock_ping): ): api_client.init(model="model-cuid", document="documentation") - mock_obtain.assert_called_once_with( - "https://env-issuer/", - "env-cid", - "openid profile email offline_access", - audience="https://api.example.com", - ) + mock_obtain.assert_called_once() + _args, kwargs = mock_obtain.call_args + self.assertEqual(_args, ("https://env-issuer/", "env-cid", "openid profile email offline_access")) + self.assertEqual(kwargs.get("audience"), "https://api.example.com") + self.assertIsNotNone(kwargs.get("credentials_backend")) self.assertEqual(api_client.get_api_host(), "http://localhost/from-env-host/") ctx = api_client._oidc_login_context assert ctx is not None @@ -621,6 +620,90 @@ def test_api_url_alias_sets_host(self, mock_obtain, mock_ping): ) self.assertEqual(api_client.get_api_host(), "http://localhost/from-api-url/") + @patch("validmind.api_client._ping") + @patch("validmind.api_client._obtain_oidc_tokens") + def test_init_passes_credentials_backend(self, mock_obtain, mock_ping): + from validmind.credentials_backend import MemoryCredentialsBackend + + backend = MemoryCredentialsBackend() + mock_obtain.return_value = { + "issuer": "https://issuer/", + "client_id": "cid", + "access_token": "tok", + "expires_at": "2099-01-01T00:00:00+00:00", + "refresh_token": None, + "id_token": None, + } + api_client.init( + model="model-cuid", + api_host="http://localhost/track/", + api_key="", + api_secret="", + issuer="https://issuer/", + client_id="cid", + credentials_backend=backend, + document="documentation", + ) + _args, kwargs = mock_obtain.call_args + self.assertIs(kwargs["credentials_backend"], backend) + + @patch("validmind.oidc_device.run_device_flow") + def test_obtain_oidc_tokens_reuses_memory_backend(self, mock_flow): + from validmind.credentials_backend import MemoryCredentialsBackend + + mock_flow.return_value = { + "issuer": "https://issuer/", + "client_id": "cid", + "access_token": "tok", + "expires_at": "2099-01-01T00:00:00+00:00", + "refresh_token": None, + "id_token": None, + } + backend = MemoryCredentialsBackend() + api_client._obtain_oidc_tokens( + "https://issuer/", + "cid", + "openid profile email", + credentials_backend=backend, + ) + mock_flow.assert_called_once() + mock_flow.reset_mock() + api_client._obtain_oidc_tokens( + "https://issuer/", + "cid", + "openid profile email", + credentials_backend=backend, + ) + mock_flow.assert_not_called() + + @patch("validmind.api_client._ping") + @patch("validmind.api_client._obtain_oidc_tokens") + def test_clear_oidc_credentials(self, mock_obtain, mock_ping): + from validmind.credentials_backend import MemoryCredentialsBackend + + backend = MemoryCredentialsBackend() + mock_obtain.return_value = { + "issuer": "https://issuer/", + "client_id": "cid", + "access_token": "tok", + "expires_at": "2099-01-01T00:00:00+00:00", + "refresh_token": "rt", + "id_token": None, + } + api_client.init( + model="model-cuid", + api_host="http://localhost/track/", + api_key="", + api_secret="", + issuer="https://issuer/", + client_id="cid", + credentials_backend=backend, + document="documentation", + ) + api_client.clear_oidc_credentials() + self.assertIsNone(api_client._oidc_login_context) + self.assertIsNone(api_client._access_token) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_credentials_backend.py b/tests/test_credentials_backend.py new file mode 100644 index 000000000..117fd87ff --- /dev/null +++ b/tests/test_credentials_backend.py @@ -0,0 +1,115 @@ +# Copyright © 2023-2026 ValidMind Inc. All rights reserved. + +import tempfile +import unittest +from pathlib import Path +from unittest.mock import patch + +from validmind.credentials_backend import ( + FileCredentialsBackend, + MemoryCredentialsBackend, + NullCredentialsBackend, + load_backend_from_env, + resolve_credentials_backend, + set_default_backend, +) +from validmind.credentials_store import credential_key, upsert_cached_entry +from validmind.errors import ValidMindAuthError + + +class TestMemoryCredentialsBackend(unittest.TestCase): + def test_roundtrip(self): + backend = MemoryCredentialsBackend() + key = credential_key("https://issuer/", "cid") + row = {"access_token": "at", "expires_at": "2099-01-01T00:00:00+00:00"} + backend.put(key, row) + self.assertEqual(backend.get(key), row) + backend.delete(key) + self.assertIsNone(backend.get(key)) + + def test_null_alias(self): + self.assertIs(NullCredentialsBackend, MemoryCredentialsBackend) + + +class TestFileCredentialsBackend(unittest.TestCase): + def test_roundtrip(self): + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "credentials.json" + backend = FileCredentialsBackend(path=path) + key = credential_key("https://issuer/", "cid") + row = {"access_token": "at", "expires_at": "2099-01-01T00:00:00+00:00"} + backend.put(key, row) + self.assertEqual(backend.get(key), row) + backend.delete(key) + self.assertIsNone(backend.get(key)) + + +class TestResolveCredentialsBackend(unittest.TestCase): + def tearDown(self): + set_default_backend(None) + + def test_explicit_wins_over_default(self): + explicit = MemoryCredentialsBackend() + default = MemoryCredentialsBackend() + set_default_backend(default) + self.assertIs(resolve_credentials_backend(explicit), explicit) + + def test_default_backend(self): + backend = MemoryCredentialsBackend() + set_default_backend(backend) + self.assertIs(resolve_credentials_backend(), backend) + + @patch.dict("os.environ", {"VM_OIDC_NO_PERSIST": "1"}, clear=True) + def test_env_no_persist(self): + backend = resolve_credentials_backend() + self.assertIsInstance(backend, MemoryCredentialsBackend) + + @patch.dict( + "os.environ", + { + "VM_OIDC_CREDENTIALS_BACKEND": ( + "validmind.credentials_backend:MemoryCredentialsBackend" + ) + }, + clear=True, + ) + def test_env_backend_spec(self): + backend = resolve_credentials_backend() + self.assertIsInstance(backend, MemoryCredentialsBackend) + + @patch.dict( + "os.environ", + {"VM_OIDC_CREDENTIALS_BACKEND": "not-a-valid-spec"}, + clear=True, + ) + def test_invalid_env_spec_raises(self): + with self.assertRaises(ValidMindAuthError): + resolve_credentials_backend() + + @patch.dict("os.environ", {}, clear=True) + def test_default_is_file_backend(self): + backend = resolve_credentials_backend() + self.assertIsInstance(backend, FileCredentialsBackend) + + +class TestLoadBackendFromEnv(unittest.TestCase): + @patch.dict("os.environ", {}, clear=True) + def test_returns_none_when_unset(self): + self.assertIsNone(load_backend_from_env()) + + @patch.dict("os.environ", {"VM_OIDC_NO_PERSIST": "true"}, clear=True) + def test_no_persist(self): + self.assertIsInstance(load_backend_from_env(), MemoryCredentialsBackend) + + +class TestCredentialsStoreWithBackend(unittest.TestCase): + def test_upsert_uses_explicit_backend(self): + backend = MemoryCredentialsBackend() + upsert_cached_entry( + "https://issuer/", + "cid", + {"access_token": "tok", "expires_at": "2099-01-01T00:00:00+00:00"}, + backend=backend, + ) + key = credential_key("https://issuer/", "cid") + self.assertEqual(backend.get(key)["access_token"], "tok") diff --git a/validmind/api_client.py b/validmind/api_client.py index 498517152..d2e4383cc 100644 --- a/validmind/api_client.py +++ b/validmind/api_client.py @@ -42,6 +42,7 @@ _auth_mode = "api_key" _access_token: Optional[str] = None _oidc_login_context: Optional[Dict[str, str]] = None +_credentials_backend: Optional[Any] = None __api_session: Optional[aiohttp.ClientSession] = None @@ -229,8 +230,11 @@ def _obtain_oidc_tokens( client_id: str, scope: str, audience: Optional[str] = None, + *, + credentials_backend: Optional[Any] = None, ) -> Dict[str, Any]: """Return a credentials entry dict with access_token, expires_at, refresh_token, etc.""" + from .credentials_backend import resolve_credentials_backend from .credentials_store import ( delete_cached_entry, get_cached_entry, @@ -241,9 +245,12 @@ def _obtain_oidc_tokens( ) from .oidc_device import run_device_flow, try_refresh_cached_tokens + backend = resolve_credentials_backend(credentials_backend) norm_issuer = normalize_issuer(issuer) norm_client_id = normalize_client_id(client_id) - cached = get_cached_entry(norm_issuer, norm_client_id, audience=audience) + cached = get_cached_entry( + norm_issuer, norm_client_id, audience=audience, backend=backend + ) if cached and not is_expired(cached): return cached if cached and cached.get("refresh_token"): @@ -256,16 +263,49 @@ def _obtain_oidc_tokens( audience=audience, ) upsert_cached_entry( - norm_issuer, norm_client_id, new_tokens, audience=audience + norm_issuer, + norm_client_id, + new_tokens, + audience=audience, + backend=backend, ) return new_tokens except ValidMindAuthError: - delete_cached_entry(norm_issuer, norm_client_id, audience=audience) + delete_cached_entry( + norm_issuer, + norm_client_id, + audience=audience, + backend=backend, + ) tokens = run_device_flow(norm_issuer, norm_client_id, scope, audience=audience) - upsert_cached_entry(norm_issuer, norm_client_id, tokens, audience=audience) + upsert_cached_entry( + norm_issuer, norm_client_id, tokens, audience=audience, backend=backend + ) return tokens +def clear_oidc_credentials() -> None: + """Remove cached OIDC tokens for the active login context.""" + global _access_token, _oidc_login_context + + if _oidc_login_context is None: + return + + from .credentials_store import delete_cached_entry + + ctx = _oidc_login_context + audience = ctx.get("audience") or None + delete_cached_entry( + ctx["issuer"], + ctx["client_id"], + audience=audience, + backend=_credentials_backend, + ) + _access_token = None + _oidc_login_context = None + _invalidate_async_session() + + def _is_entra_issuer(issuer: str) -> bool: return "login.microsoftonline.com" in issuer.lower() @@ -289,6 +329,7 @@ def init( client_id: Optional[str] = None, scope: Optional[str] = None, audience: Optional[str] = None, + credentials_backend: Optional[Any] = None, ): """ Initializes the API client instances and calls the /ping endpoint to ensure @@ -299,8 +340,10 @@ def init( Alternatively, pass ``issuer`` and ``client_id`` or set their ``VM_OIDC_*`` environment variables to authenticate via the OIDC device authorization flow - (RFC 8628). Tokens are cached under ``~/.validmind/credentials.json``. Do not - combine API keys with OIDC parameters. + (RFC 8628). Tokens are cached via an :class:`~validmind.credentials_backend.OidcCredentialsBackend` + (default: ``~/.validmind/credentials.json``). Pass ``credentials_backend`` or set + ``VM_OIDC_CREDENTIALS_BACKEND`` / ``VM_OIDC_NO_PERSIST=1`` for custom or memory-only + storage. Do not combine API keys with OIDC parameters. Args: model (str, optional): The model CUID. Defaults to None. @@ -323,6 +366,9 @@ def init( (e.g. Auth0 API Identifier). Use the same value the ValidMind backend expects as ``api_audience`` so the provider can issue RS256 API tokens. Can be set via env ``VM_OIDC_AUDIENCE``. + credentials_backend (optional): Pluggable OIDC token store implementing + ``get`` / ``put`` / ``delete``. Defaults to file storage unless overridden + by ``VM_OIDC_CREDENTIALS_BACKEND`` or ``VM_OIDC_NO_PERSIST=1``. Raises: MissingAPICredentialsError: If neither API keys nor OIDC parameters can be resolved. @@ -330,7 +376,7 @@ def init( ValidMindAuthError: If OIDC configuration conflicts or login fails. """ global _api_key, _api_secret, _api_host, _model_cuid, _monitoring, _document - global _auth_mode, _access_token, _oidc_login_context + global _auth_mode, _access_token, _oidc_login_context, _credentials_backend if api_key == "...": # special case to detect when running a notebook placeholder (...) @@ -399,8 +445,15 @@ def init( audience if audience is not None else os.getenv("VM_OIDC_AUDIENCE") ) oidc_audience_opt = oidc_audience_val or None + from .credentials_backend import resolve_credentials_backend + + _credentials_backend = resolve_credentials_backend(credentials_backend) entry = _obtain_oidc_tokens( - oidc_issuer, oidc_client_id, scope_val, audience=oidc_audience_opt + oidc_issuer, + oidc_client_id, + scope_val, + audience=oidc_audience_opt, + credentials_backend=_credentials_backend, ) _access_token = _select_oidc_bearer_token(entry) _oidc_login_context = { @@ -416,6 +469,7 @@ def init( _auth_mode = "api_key" _access_token = None _oidc_login_context = None + _credentials_backend = None _api_key = env_key _api_secret = env_secret _api_host = resolved_host diff --git a/validmind/credentials_backend.py b/validmind/credentials_backend.py new file mode 100644 index 000000000..61916840d --- /dev/null +++ b/validmind/credentials_backend.py @@ -0,0 +1,192 @@ +# Copyright © 2023-2026 ValidMind Inc. All rights reserved. +# Refer to the LICENSE file in the root of this repository for details. +# SPDX-License-Identifier: AGPL-3.0 AND ValidMind Commercial + +"""Pluggable OIDC token persistence for library authentication.""" + +from __future__ import annotations + +import importlib +import json +import os +from pathlib import Path +from typing import Any, Dict, Optional, Protocol, runtime_checkable + +from .errors import ValidMindAuthError + +_ENV_BACKEND_SPEC = "VM_OIDC_CREDENTIALS_BACKEND" +_ENV_BACKEND_KWARGS = "VM_OIDC_CREDENTIALS_BACKEND_KWARGS" +_ENV_NO_PERSIST = "VM_OIDC_NO_PERSIST" + +_default_backend: Optional["OidcCredentialsBackend"] = None + + +@runtime_checkable +class OidcCredentialsBackend(Protocol): + """Persist OIDC token entries keyed by :func:`credentials_store.credential_key`.""" + + def get(self, key: str) -> Optional[Dict[str, Any]]: + """Return a cached token entry or ``None``.""" + ... + + def put(self, key: str, entry: Dict[str, Any]) -> None: + """Persist a token entry.""" + ... + + def delete(self, key: str) -> None: + """Remove a cached token entry.""" + ... + + +def set_default_backend(backend: Optional[OidcCredentialsBackend]) -> None: + """Register a process-wide default backend (e.g. from IPython startup).""" + global _default_backend + _default_backend = backend + + +def get_default_backend() -> Optional[OidcCredentialsBackend]: + """Return the registered process-wide default backend, if any.""" + return _default_backend + + +def _truthy_env(name: str) -> bool: + return os.getenv(name, "").strip().lower() in ("1", "true", "yes") + + +def _validate_backend(backend: Any, spec: str) -> OidcCredentialsBackend: + if isinstance(backend, OidcCredentialsBackend): + return backend + for method in ("get", "put", "delete"): + if not callable(getattr(backend, method, None)): + raise ValidMindAuthError( + f"OIDC credentials backend {spec!r} must implement get, put, and delete" + ) + return backend # type: ignore[return-value] + + +def load_backend_from_env() -> Optional[OidcCredentialsBackend]: + """Instantiate a backend from ``VM_OIDC_*`` environment variables.""" + if _truthy_env(_ENV_NO_PERSIST): + return MemoryCredentialsBackend() + spec = os.getenv(_ENV_BACKEND_SPEC, "").strip() + if not spec: + return None + return _instantiate_backend(spec) + + +def _instantiate_backend(spec: str) -> OidcCredentialsBackend: + module_path, sep, class_name = spec.partition(":") + if not sep: + raise ValidMindAuthError( + "VM_OIDC_CREDENTIALS_BACKEND must be module.path:ClassName, " + f"got {spec!r}" + ) + try: + module = importlib.import_module(module_path) + cls = getattr(module, class_name) + except (ImportError, AttributeError) as exc: + raise ValidMindAuthError( + f"Could not load OIDC credentials backend {spec!r}: {exc}" + ) from exc + + kwargs: Dict[str, Any] = {} + raw_kwargs = os.getenv(_ENV_BACKEND_KWARGS, "").strip() + if raw_kwargs: + try: + parsed = json.loads(raw_kwargs) + except json.JSONDecodeError as exc: + raise ValidMindAuthError( + f"VM_OIDC_CREDENTIALS_BACKEND_KWARGS is not valid JSON: {exc}" + ) from exc + if not isinstance(parsed, dict): + raise ValidMindAuthError( + "VM_OIDC_CREDENTIALS_BACKEND_KWARGS must be a JSON object" + ) + kwargs = parsed + + try: + backend = cls(**kwargs) + except TypeError as exc: + raise ValidMindAuthError( + f"Could not construct OIDC credentials backend {spec!r}: {exc}" + ) from exc + + return _validate_backend(backend, spec) + + +def resolve_credentials_backend( + explicit: Optional[OidcCredentialsBackend] = None, +) -> OidcCredentialsBackend: + """ + Resolve the active credentials backend. + + Precedence: explicit argument → :func:`set_default_backend` → environment → + :class:`FileCredentialsBackend`. + """ + if explicit is not None: + return explicit + if _default_backend is not None: + return _default_backend + from_env = load_backend_from_env() + if from_env is not None: + return from_env + return FileCredentialsBackend() + + +class MemoryCredentialsBackend: + """In-process token store; nothing is written to disk.""" + + def __init__(self) -> None: + self._entries: Dict[str, Dict[str, Any]] = {} + + def get(self, key: str) -> Optional[Dict[str, Any]]: + entry = self._entries.get(key) + return dict(entry) if entry else None + + def put(self, key: str, entry: Dict[str, Any]) -> None: + self._entries[key] = dict(entry) + + def delete(self, key: str) -> None: + self._entries.pop(key, None) + + +# Explicit alias for documentation and env-based registration. +NullCredentialsBackend = MemoryCredentialsBackend + + +class FileCredentialsBackend: + """Default backend: ``~/.validmind/credentials.json`` with mode ``0600``.""" + + def __init__(self, path: Optional[Path] = None) -> None: + from .credentials_store import credentials_path + + self._path = path or credentials_path() + + @property + def path(self) -> Path: + return self._path + + def get(self, key: str) -> Optional[Dict[str, Any]]: + from .credentials_store import load_credentials_file + + data = load_credentials_file(self._path) + entry = data.get("credentials", {}).get(key) + return dict(entry) if entry else None + + def put(self, key: str, entry: Dict[str, Any]) -> None: + from .credentials_store import load_credentials_file, save_credentials_file + + data = load_credentials_file(self._path) + credentials = dict(data.get("credentials", {})) + credentials[key] = dict(entry) + data["credentials"] = credentials + save_credentials_file(data, self._path) + + def delete(self, key: str) -> None: + from .credentials_store import load_credentials_file, save_credentials_file + + data = load_credentials_file(self._path) + credentials = dict(data.get("credentials", {})) + credentials.pop(key, None) + data["credentials"] = credentials + save_credentials_file(data, self._path) diff --git a/validmind/credentials_store.py b/validmind/credentials_store.py index 2f3f9eb7f..307f50646 100644 --- a/validmind/credentials_store.py +++ b/validmind/credentials_store.py @@ -2,7 +2,7 @@ # Refer to the LICENSE file in the root of this repository for details. # SPDX-License-Identifier: AGPL-3.0 AND ValidMind Commercial -"""Persist OIDC tokens for library authentication under ``~/.validmind/``.""" +"""OIDC token persistence helpers and normalization utilities.""" from __future__ import annotations @@ -11,10 +11,13 @@ import tempfile from datetime import datetime, timedelta, timezone from pathlib import Path -from typing import Any, Dict, Optional +from typing import TYPE_CHECKING, Any, Dict, Optional from .errors import ValidMindAuthError +if TYPE_CHECKING: + from .credentials_backend import OidcCredentialsBackend + _CREDENTIALS_VERSION = 1 @@ -108,18 +111,30 @@ def save_credentials_file(data: Dict[str, Any], path: Optional[Path] = None) -> _atomic_write(path, data) +def _resolve_backend( + backend: Optional[OidcCredentialsBackend] = None, + path: Optional[Path] = None, +) -> OidcCredentialsBackend: + from .credentials_backend import FileCredentialsBackend, resolve_credentials_backend + + if backend is not None: + return backend + if path is not None: + return FileCredentialsBackend(path=path) + return resolve_credentials_backend() + + def get_cached_entry( issuer: str, client_id: str, path: Optional[Path] = None, audience: Optional[str] = None, + *, + backend: Optional[OidcCredentialsBackend] = None, ) -> Optional[Dict[str, Any]]: key = credential_key(issuer, client_id, audience) - data = load_credentials_file(path) - entry = data.get("credentials", {}).get(key) - if not entry: - return None - return dict(entry) + store = _resolve_backend(backend=backend, path=path) + return store.get(key) def upsert_cached_entry( @@ -128,12 +143,12 @@ def upsert_cached_entry( entry: Dict[str, Any], path: Optional[Path] = None, audience: Optional[str] = None, + *, + backend: Optional[OidcCredentialsBackend] = None, ) -> None: key = credential_key(issuer, client_id, audience) norm_issuer = normalize_issuer(issuer) aud = normalize_audience(audience) - data = load_credentials_file(path) - credentials = dict(data.get("credentials", {})) row = { "issuer": norm_issuer, "client_id": client_id, @@ -141,9 +156,8 @@ def upsert_cached_entry( } if aud: row["audience"] = aud - credentials[key] = row - data["credentials"] = credentials - save_credentials_file(data, path) + store = _resolve_backend(backend=backend, path=path) + store.put(key, row) def delete_cached_entry( @@ -151,13 +165,12 @@ def delete_cached_entry( client_id: str, path: Optional[Path] = None, audience: Optional[str] = None, + *, + backend: Optional[OidcCredentialsBackend] = None, ) -> None: key = credential_key(issuer, client_id, audience) - data = load_credentials_file(path) - credentials = dict(data.get("credentials", {})) - credentials.pop(key, None) - data["credentials"] = credentials - save_credentials_file(data, path) + store = _resolve_backend(backend=backend, path=path) + store.delete(key) def is_expired(entry: Dict[str, Any], skew_seconds: int = 120) -> bool: