From 87b6f4dc3b6b1034d5c231d1ef6cb05c7669d877 Mon Sep 17 00:00:00 2001 From: Spencer Krum Date: Tue, 14 Jul 2026 16:04:30 +0000 Subject: [PATCH] Add offline_access to default OIDC scope --- docs/oidc-device-flow-release-notes.md | 6 ++--- tests/test_api_client.py | 35 ++++++++++++++++++++++++++ validmind/api_client.py | 4 +-- 3 files changed, 40 insertions(+), 5 deletions(-) diff --git a/docs/oidc-device-flow-release-notes.md b/docs/oidc-device-flow-release-notes.md index f420b70f0..455632b11 100644 --- a/docs/oidc-device-flow-release-notes.md +++ b/docs/oidc-device-flow-release-notes.md @@ -21,7 +21,7 @@ The SDK does **OIDC discovery** against your issuer. From `{issuer}/.well-known/ - Supply ValidMind with values that match what the **ValidMind platform** already trusts for your organization: - **Issuer URL** — Normalized OpenID Provider issuer (example Entra v2: `https://login.microsoftonline.com//v2.0`). - **Client ID** — The application (client) ID of the public OAuth client. - - **Scopes** — The library defaults to `openid profile email`. Your IdP may require **additional scopes** or a **resource identifier** so the access token is acceptable to the ValidMind API. + - **Scopes** — The library defaults to `openid profile email offline_access` so OIDC providers can issue refresh tokens. Your IdP may require **additional scopes** or a **resource identifier** so the access token is acceptable to the ValidMind API. - **Audience / API identifier (often required)** — Access tokens must pass ValidMind’s JWT validation (issuer, audience, signing keys). Many setups need an explicit **audience** for API-style access tokens (e.g. Auth0 API Identifier, or Azure AD custom scope / resource). Pass this as the `audience` argument (or set env `VM_OIDC_AUDIENCE`) so the provider issues tokens ValidMind can verify. **Operational checks** @@ -56,7 +56,7 @@ vm.init( client_id="", model="", api_host="https://.../api/v1/tracking/", # or api_url= (alias); defaults from VM_API_HOST if unset - scope="openid profile email", # optional; this is the default if omitted + scope="openid profile email offline_access", # optional; this is the default if omitted audience="", # optional; often required for API tokens; or VM_OIDC_AUDIENCE ) ``` @@ -101,7 +101,7 @@ Org and model access are enforced server-side: the user must be allowed to use t | Client ID | Public OAuth client for device flow | | Model CUID | Identifies the inventory model (`model=` or `VM_API_MODEL`) | | API host / URL | Tracking API base (`api_host`, `api_url`, or `VM_API_HOST`) | -| Scope | OAuth scopes (default `openid profile email`) | +| Scope | OAuth scopes (default `openid profile email offline_access`) | | 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) | diff --git a/tests/test_api_client.py b/tests/test_api_client.py index 072546beb..6b77711f0 100644 --- a/tests/test_api_client.py +++ b/tests/test_api_client.py @@ -529,6 +529,41 @@ def test_init_oidc_passes_audience(self, mock_obtain, mock_ping): assert ctx is not None self.assertEqual(ctx["audience"], "https://api.example.com") + @patch("validmind.api_client._ping") + @patch("validmind.api_client._obtain_oidc_tokens") + def test_init_oidc_uses_default_scope_with_offline_access( + self, mock_obtain, mock_ping + ): + mock_obtain.return_value = { + "issuer": "https://issuer/", + "client_id": "cid", + "access_token": "tok", + "expires_at": "2099-01-01T00:00:00+00:00", + "refresh_token": "refresh-token", + "id_token": None, + } + + with patch.dict(os.environ, {"VM_OIDC_SCOPE": ""}): + api_client.init( + model="model-cuid", + api_host="http://localhost/track/", + api_key="", + api_secret="", + issuer="https://issuer/", + client_id="cid", + document="documentation", + ) + + mock_obtain.assert_called_once_with( + "https://issuer/", + "cid", + "openid profile email offline_access", + audience=None, + ) + ctx = api_client._oidc_login_context + assert ctx is not None + self.assertEqual(ctx["scope"], "openid profile email offline_access") + @patch("validmind.api_client._ping") @patch("validmind.api_client._obtain_oidc_tokens") def test_init_oidc_uses_env_config(self, mock_obtain, mock_ping): diff --git a/validmind/api_client.py b/validmind/api_client.py index 498517152..c5c4603c0 100644 --- a/validmind/api_client.py +++ b/validmind/api_client.py @@ -317,7 +317,7 @@ def init( Can be set via env ``VM_OIDC_ISSUER``. client_id (str, optional): OAuth public client id for device flow. Can be set via env ``VM_OIDC_CLIENT_ID``. - scope (str, optional): OAuth scopes (default ``openid profile email``). + scope (str, optional): OAuth scopes (default ``openid profile email offline_access``). Can be set via env ``VM_OIDC_SCOPE``. audience (str, optional): Resource / API identifier for the access token (e.g. Auth0 API Identifier). Use the same value the ValidMind backend @@ -392,7 +392,7 @@ def init( _api_key = None _api_secret = None _api_host = resolved_host - scope_val = oidc_scope or "openid profile email" + scope_val = oidc_scope or "openid profile email offline_access" from .credentials_store import normalize_audience oidc_audience_val = normalize_audience(