From a2dd94f7a4001f49b1d9650669cf9223650a0a92 Mon Sep 17 00:00:00 2001
From: Prasad <prasad@albertinvent.com>
Date: Tue, 19 May 2026 14:53:58 +0530
Subject: [PATCH 1/4] feat(auth): add AlbertSSOTokenExchange for Azure AD SSO
 token exchange

Adds a new auth strategy for applications that authenticate users via
Azure AD and want to access the Albert API without browser interaction.

- AlbertSSOTokenExchange exchanges an Azure AD ID token for an Albert
  access token via POST /api/v3/login/sso/exchange; accepts an
  oidc_token_provider callable so tokens are re-fetched on renewal
- Uses expires_in from response to schedule refresh; falls back to
  55 min until backend ships the field
- Albert.from_sso_exchange() and AsyncAlbert.from_sso_exchange() factory
  methods; AlbertSSOTokenExchange exported from top-level albert package
- Docs: sso_exchange.md API reference, Azure AD section in
  authentication.md with prerequisites and warnings, mkdocs.yml nav entry
---
 docs/authentication.md               |  69 ++++++++++++++++-
 docs/sso_exchange.md                 |   3 +
 mkdocs.yml                           |   1 +
 src/albert/__init__.py               |   9 ++-
 src/albert/client.py                 | 110 ++++++++++++++++++++++++++-
 src/albert/core/auth/__init__.py     |   3 +
 src/albert/core/auth/sso_exchange.py |  87 +++++++++++++++++++++
 7 files changed, 276 insertions(+), 6 deletions(-)
 create mode 100644 docs/sso_exchange.md
 create mode 100644 src/albert/core/auth/sso_exchange.py

diff --git a/docs/authentication.md b/docs/authentication.md
index 3b129600c..52a393f5e 100644
--- a/docs/authentication.md
+++ b/docs/authentication.md
@@ -1,8 +1,9 @@
 # Authentication
 
-Albert Python SDK supports three authentication methods:
+Albert Python SDK supports four authentication methods:
 
 * **Single Sign-On (SSO)** via browser-based OAuth2
+* **Azure AD SSO Token Exchange** for server-to-server integrations using Azure AD
 * **Client Credentials** using a client ID and secret
 * **Static Token** using a pre-generated token (via the `ALBERT_TOKEN` environment variable)
 
@@ -46,6 +47,72 @@ client = Albert.from_sso(
 
 ---
 
+## 🔄 Azure AD SSO Token Exchange
+
+This method is for applications that already authenticate users through **Azure Active Directory**
+and want to access the Albert API on their behalf — without any browser interaction. Your
+application obtains an Azure AD ID token and the SDK exchanges it for an Albert access token
+automatically.
+
+!!! warning "Tenant configuration required"
+    This authentication method requires your Azure AD application's `aud` (audience/client ID)
+    to be registered with Albert for your tenant. Without this, all requests will return `401 Unauthorized`.
+    [Contact Albert support](https://support.albertinvent.com/en/contact-us) to enable this for your organisation.
+
+### Prerequisites
+
+- An Azure AD application registration with the Albert API audience registered by Albert support
+- A mechanism in your application to obtain an Azure AD ID token (e.g. MSAL, Azure SDK)
+
+### Usage
+
+Provide a callable that returns a fresh Azure AD ID token on demand. The SDK calls it on the
+first request and again whenever the Albert access token needs to be renewed.
+
+```python
+from albert import Albert, AlbertSSOTokenExchange
+from msal import ConfidentialClientApplication
+
+app = ConfidentialClientApplication(
+    client_id="your-azure-app-id",
+    client_credential="your-azure-client-secret",
+    authority="https://login.microsoftonline.com/your-tenant-id",
+)
+
+def get_azure_token() -> str:
+    result = app.acquire_token_for_client(scopes=["api://your-albert-audience/.default"])
+    return result["id_token"]
+
+client = Albert.from_sso_exchange(
+    base_url="https://mycompany.albertinvent.com",
+    subdomain="mycompany",
+    oidc_token_provider=get_azure_token,
+)
+```
+
+Or wire it up manually:
+
+```python
+auth = AlbertSSOTokenExchange(
+    base_url="https://mycompany.albertinvent.com",
+    subdomain="mycompany",
+    oidc_token_provider=get_azure_token,
+)
+client = Albert(auth_manager=auth)
+```
+
+!!! warning "Use a callable, not a static token string"
+    Passing `lambda: "my-static-token"` works only while that Azure AD token remains valid
+    (typically 60–90 minutes). Once it expires and the Albert access token needs renewal,
+    the exchange will fail. Always pass a callable that fetches a fresh token from Azure AD.
+
+!!! warning "Token validity is your responsibility"
+    The SDK passes the token returned by `oidc_token_provider` directly to Albert. If your
+    callable returns an expired or invalid Azure AD token, the exchange will fail with
+    `401 Unauthorized`. Ensure your token acquisition logic handles Azure AD token refresh.
+
+---
+
 ## 🔑 Client Credentials (Programmatic Access)
 
 This method implements the OAuth2 Client Credentials flow and is suitable for non-interactive usage, like backend services or automation scripts. It manages token acquisition and refresh automatically via the `AlbertClientCredentials` class.
diff --git a/docs/sso_exchange.md b/docs/sso_exchange.md
new file mode 100644
index 000000000..0f9e69189
--- /dev/null
+++ b/docs/sso_exchange.md
@@ -0,0 +1,3 @@
+# Azure AD SSO Token Exchange
+
+::: albert.AlbertSSOTokenExchange
diff --git a/mkdocs.yml b/mkdocs.yml
index d8101a2a5..6103d2fda 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -131,6 +131,7 @@ nav:
       - Authentication:
           - Albert Client Credentials: credentials.md
           - Albert SSO Client: sso.md
+          - Azure AD SSO Token Exchange: sso_exchange.md
       - Collections:
           - Activities: collections/activities.md
           - Attachments: collections/attachments.md
diff --git a/src/albert/__init__.py b/src/albert/__init__.py
index 3e310bc75..53ad7ca1f 100644
--- a/src/albert/__init__.py
+++ b/src/albert/__init__.py
@@ -1,7 +1,14 @@
 from albert.client import Albert, AsyncAlbert
 from albert.core.auth.credentials import AlbertClientCredentials
 from albert.core.auth.sso import AlbertSSOClient
+from albert.core.auth.sso_exchange import AlbertSSOTokenExchange
 
-__all__ = ["Albert", "AsyncAlbert", "AlbertClientCredentials", "AlbertSSOClient"]
+__all__ = [
+    "Albert",
+    "AsyncAlbert",
+    "AlbertClientCredentials",
+    "AlbertSSOClient",
+    "AlbertSSOTokenExchange",
+]
 
 __version__ = "1.25.0"
diff --git a/src/albert/client.py b/src/albert/client.py
index 8a7911902..2cc632af2 100644
--- a/src/albert/client.py
+++ b/src/albert/client.py
@@ -1,6 +1,7 @@
 from __future__ import annotations
 
 import os
+from collections.abc import Callable
 
 from pydantic import SecretStr
 
@@ -56,6 +57,7 @@
 from albert.core.async_session import AsyncAlbertSession
 from albert.core.auth.credentials import AlbertClientCredentials
 from albert.core.auth.sso import AlbertSSOClient
+from albert.core.auth.sso_exchange import AlbertSSOTokenExchange
 from albert.core.session import AlbertSession
 from albert.utils._auth import default_albert_base_url
 
@@ -76,7 +78,7 @@ class Albert:
     token : str, optional
         A static token for authentication. If provided, it overrides any `auth_manager`.
         Defaults to the "ALBERT_TOKEN" environment variable.
-    auth_manager : AlbertClientCredentials | AlbertSSOClient, optional
+    auth_manager : AlbertClientCredentials | AlbertSSOClient | AlbertSSOTokenExchange, optional
         An authentication manager for OAuth2-based authentication flows.
         Ignored if `token` is provided.
     retries : int, optional
@@ -103,6 +105,7 @@ class Albert:
     - `from_token` — Create a client using a static token.
     - `from_sso` — Create a client using interactive browser-based SSO login.
     - `from_client_credentials` — Create a client using OAuth2 client credentials.
+    - `from_sso_exchange` — Create a client using server-to-server OIDC token exchange.
     """
 
     def __init__(
@@ -110,7 +113,10 @@ def __init__(
         *,
         base_url: str | None = None,
         token: str | None = None,
-        auth_manager: AlbertClientCredentials | AlbertSSOClient | None = None,
+        auth_manager: AlbertClientCredentials
+        | AlbertSSOClient
+        | AlbertSSOTokenExchange
+        | None = None,
         retries: int | None = None,
         session: AlbertSession | None = None,
     ):
@@ -169,6 +175,65 @@ def from_client_credentials(
         )
         return cls(auth_manager=creds, retries=retries)
 
+    @classmethod
+    def from_sso_exchange(
+        cls,
+        *,
+        base_url: str | None = None,
+        subdomain: str,
+        oidc_token_provider: Callable[[], str],
+        retries: int | None = None,
+    ) -> Albert:
+        """Create an Albert client using server-to-server OIDC token exchange.
+
+        Exchanges an OpenID Connect ID token for an Albert access token without
+        any browser interaction. Suitable for custom applications that already
+        authenticate users via their own identity provider (e.g. Okta, Azure AD).
+
+        Requires tenant-level OIDC configuration — the OpenID Connect ``aud`` claim
+        must be registered with Albert for the target tenant.
+
+        Parameters
+        ----------
+        base_url : str | None, optional
+            The base URL of the Albert API. Falls back to the ``ALBERT_BASE_URL``
+            environment variable or "https://app.albertinvent.com".
+        subdomain : str
+            The tenant subdomain (e.g. ``"mycompany"``).
+        oidc_token_provider : Callable[[], str]
+            A zero-argument callable that returns a fresh OIDC ID token on demand.
+            Called on the first request and again if the Albert refresh token expires.
+            A lambda returning a static string works for short-lived sessions.
+        retries : int | None, optional
+            Maximum number of retries for failed HTTP requests.
+
+        Returns
+        -------
+        Albert
+            A configured client that exchanges and refreshes tokens automatically.
+
+        Examples
+        --------
+        ```python
+        def get_okta_token() -> str:
+            resp = requests.post("https://mycompany.okta.com/oauth2/token", data={...})
+            return resp.json()["id_token"]
+
+        client = Albert.from_sso_exchange(
+            base_url="https://mycompany.albertinvent.com",
+            subdomain="mycompany",
+            oidc_token_provider=get_okta_token,
+        )
+        ```
+        """
+        resolved_base_url = base_url or default_albert_base_url()
+        auth = AlbertSSOTokenExchange(
+            base_url=resolved_base_url,
+            subdomain=subdomain,
+            oidc_token_provider=oidc_token_provider,
+        )
+        return cls(auth_manager=auth, retries=retries)
+
     @property
     def projects(self) -> ProjectCollection:
         return ProjectCollection(session=self.session)
@@ -374,7 +439,7 @@ class AsyncAlbert:
     token : str, optional
         A static JWT token. Ignored when ``auth_manager`` is provided.
         Defaults to the ``ALBERT_TOKEN`` environment variable.
-    auth_manager : AlbertClientCredentials | AlbertSSOClient, optional
+    auth_manager : AlbertClientCredentials | AlbertSSOClient | AlbertSSOTokenExchange, optional
         An authentication manager for OAuth2-based token refresh.
         Ignored if ``token`` is provided.
     session : AsyncAlbertSession, optional
@@ -419,7 +484,10 @@ def __init__(
         *,
         base_url: str | None = None,
         token: str | None = None,
-        auth_manager: AlbertClientCredentials | AlbertSSOClient | None = None,
+        auth_manager: AlbertClientCredentials
+        | AlbertSSOClient
+        | AlbertSSOTokenExchange
+        | None = None,
         session: AsyncAlbertSession | None = None,
     ):
         if session is not None:
@@ -495,6 +563,40 @@ def from_client_credentials(
         )
         return cls(auth_manager=creds)
 
+    @classmethod
+    def from_sso_exchange(
+        cls,
+        *,
+        base_url: str | None = None,
+        subdomain: str,
+        oidc_token_provider: Callable[[], str],
+    ) -> AsyncAlbert:
+        """
+        Create an AsyncAlbert client using server-to-server OIDC token exchange.
+
+        Parameters
+        ----------
+        base_url : str | None, optional
+            The base URL of the Albert API. Falls back to the ``ALBERT_BASE_URL``
+            environment variable or "https://app.albertinvent.com".
+        subdomain : str
+            The tenant subdomain (e.g. ``"mycompany"``).
+        oidc_token_provider : Callable[[], str]
+            A zero-argument callable that returns a fresh OIDC ID token on demand.
+
+        Returns
+        -------
+        AsyncAlbert
+            A configured async client that exchanges and refreshes tokens automatically.
+        """
+        resolved_base_url = base_url or default_albert_base_url()
+        auth = AlbertSSOTokenExchange(
+            base_url=resolved_base_url,
+            subdomain=subdomain,
+            oidc_token_provider=oidc_token_provider,
+        )
+        return cls(auth_manager=auth)
+
     @property
     def chat_sessions(self) -> ChatSessionCollection:
         return ChatSessionCollection(session=self.session)
diff --git a/src/albert/core/auth/__init__.py b/src/albert/core/auth/__init__.py
index e69de29bb..797a1a069 100644
--- a/src/albert/core/auth/__init__.py
+++ b/src/albert/core/auth/__init__.py
@@ -0,0 +1,3 @@
+from albert.core.auth.sso_exchange import AlbertSSOTokenExchange
+
+__all__ = ["AlbertSSOTokenExchange"]
diff --git a/src/albert/core/auth/sso_exchange.py b/src/albert/core/auth/sso_exchange.py
new file mode 100644
index 000000000..7fcced9cb
--- /dev/null
+++ b/src/albert/core/auth/sso_exchange.py
@@ -0,0 +1,87 @@
+from __future__ import annotations
+
+from collections.abc import Callable
+from datetime import datetime, timedelta, timezone
+from urllib.parse import urljoin
+
+import requests
+from pydantic import Field
+
+from albert.core.auth._manager import AuthManager, OAuthTokenInfo
+from albert.core.base import BaseAlbertModel
+from albert.exceptions import handle_http_errors
+from albert.utils._auth import default_albert_base_url
+
+
+class AlbertSSOTokenExchange(BaseAlbertModel, AuthManager):
+    """
+    Auth manager for server-to-server OIDC token exchange with the Albert API.
+
+    Exchanges an OpenID Connect ID token for an Albert access token without
+    any browser interaction. Suitable for custom applications that already
+    authenticate users via their own OIDC identity provider (e.g. Okta, Azure AD).
+
+    Requires tenant-level OIDC configuration: the OpenID Connect ``aud`` claim
+    must be registered with Albert for the target tenant. Contact Albert support
+    to enable this feature.
+
+    Parameters
+    ----------
+    base_url : str
+        The base URL of the Albert API.
+    subdomain : str
+        The tenant subdomain (e.g. ``"mycompany"``).
+    oidc_token_provider : Callable[[], str]
+        A zero-argument callable that returns a fresh OIDC ID token on demand.
+        Called on the first request and on every token renewal.
+        A lambda returning a static string works for short-lived sessions.
+
+    Usage
+    -----
+    ```python
+    def get_okta_token() -> str:
+        resp = requests.post("https://mycompany.okta.com/oauth2/token", data={...})
+        return resp.json()["id_token"]
+
+    auth = AlbertSSOTokenExchange(
+        base_url="https://mycompany.albertinvent.com",
+        subdomain="mycompany",
+        oidc_token_provider=get_okta_token,
+    )
+    client = Albert(auth_manager=auth)
+    ```
+    """
+
+    base_url: str = Field(default_factory=default_albert_base_url)
+    subdomain: str
+    oidc_token_provider: Callable[[], str]
+
+    @property
+    def exchange_url(self) -> str:
+        return urljoin(self.base_url, "/api/v3/login/sso/exchange")
+
+    def _exchange(self) -> None:
+        with handle_http_errors():
+            response = requests.post(
+                self.exchange_url,
+                json={"jwt": self.oidc_token_provider(), "subdomain": self.subdomain},
+            )
+            response.raise_for_status()
+        data = response.json()
+        expires_in = data.get(
+            "expires_in", 3300
+        )  # fallback: 55 min until backend ships expires_in
+        self._token_info = OAuthTokenInfo(
+            access_token=data["jwt"],
+            refresh_token=data["refreshtoken"],
+            expires_in=expires_in,
+        )
+        self._refresh_time = (
+            datetime.now(timezone.utc) + timedelta(seconds=expires_in) - timedelta(minutes=1)
+        )
+
+    def get_access_token(self) -> str:
+        """Return a valid Albert access token, re-exchanging via the OIDC provider if needed."""
+        if self._requires_refresh():
+            self._exchange()
+        return self._token_info.access_token

From 925255e139183792d92c27348de905cff35c0dc0 Mon Sep 17 00:00:00 2001
From: Prasad <prasad@albertinvent.com>
Date: Wed, 20 May 2026 11:56:14 +0530
Subject: [PATCH 2/4] fix(auth): broaden OIDC token exchange to all
 OIDC-compliant providers
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Remove Azure AD-specific framing — the exchange endpoint supports any IdP
that emits preferred_username (Okta, Auth0, Cognito, Keycloak, Ping, etc).
Add provider compatibility table, Google claim-mapping warning, and SAML
exclusion note in docs. Update code docstrings accordingly.
---
 docs/authentication.md               | 127 +++++++++++++++++++--------
 docs/sso_exchange.md                 |   2 +-
 mkdocs.yml                           |   2 +-
 src/albert/client.py                 |  18 ++--
 src/albert/core/auth/sso_exchange.py |  18 ++--
 5 files changed, 115 insertions(+), 52 deletions(-)

diff --git a/docs/authentication.md b/docs/authentication.md
index 52a393f5e..dc703ecfd 100644
--- a/docs/authentication.md
+++ b/docs/authentication.md
@@ -3,7 +3,7 @@
 Albert Python SDK supports four authentication methods:
 
 * **Single Sign-On (SSO)** via browser-based OAuth2
-* **Azure AD SSO Token Exchange** for server-to-server integrations using Azure AD
+* **OIDC Token Exchange** for server-to-server integrations using any OIDC-compliant identity provider
 * **Client Credentials** using a client ID and secret
 * **Static Token** using a pre-generated token (via the `ALBERT_TOKEN` environment variable)
 
@@ -47,69 +47,124 @@ client = Albert.from_sso(
 
 ---
 
-## 🔄 Azure AD SSO Token Exchange
+## 🔄 OIDC Token Exchange
 
-This method is for applications that already authenticate users through **Azure Active Directory**
-and want to access the Albert API on their behalf — without any browser interaction. Your
-application obtains an Azure AD ID token and the SDK exchanges it for an Albert access token
-automatically.
+This method is for applications that already authenticate users through an **OIDC-compliant
+identity provider** and want to access the Albert API on their behalf — without any browser
+interaction. Your application obtains an OIDC ID token and the SDK exchanges it for an Albert
+access token automatically.
 
 !!! warning "Tenant configuration required"
-    This authentication method requires your Azure AD application's `aud` (audience/client ID)
+    This authentication method requires your identity provider's `aud` (audience/client ID)
     to be registered with Albert for your tenant. Without this, all requests will return `401 Unauthorized`.
     [Contact Albert support](https://support.albertinvent.com/en/contact-us) to enable this for your organisation.
 
-### Prerequisites
+### Supported Identity Providers
 
-- An Azure AD application registration with the Albert API audience registered by Albert support
-- A mechanism in your application to obtain an Azure AD ID token (e.g. MSAL, Azure SDK)
+Any OIDC-compliant identity provider that includes the `preferred_username` claim in the ID
+token is supported. This includes:
 
-### Usage
+| Provider | Notes |
+|---|---|
+| Microsoft Entra ID (Azure AD) | Supported via v2.0 endpoints |
+| Okta | OIDC app integrations only (not SAML) |
+| Auth0 | Supported |
+| AWS Cognito | User pools with OIDC |
+| Ping Identity | PingOne and PingFederate OIDC |
+| Keycloak | Supported |
+| OneLogin, ForgeRock, IBM Security Verify | Supported |
+| Salesforce Identity | OIDC configuration required |
+| Self-hosted OIDC servers | Authentik, Dex, Hydra, etc. |
 
-Provide a callable that returns a fresh Azure AD ID token on demand. The SDK calls it on the
-first request and again whenever the Albert access token needs to be renewed.
+!!! warning "Google Identity / Workspace"
+    `preferred_username` is not emitted by default in Google's OIDC tokens. You must configure
+    a custom claim mapping in your Google OAuth app to include it before this flow will work.
 
-```python
-from albert import Albert, AlbertSSOTokenExchange
-from msal import ConfidentialClientApplication
+!!! info "SAML providers are not supported"
+    Identity providers configured to use SAML only (rather than OIDC) are not compatible with
+    this authentication method.
 
-app = ConfidentialClientApplication(
-    client_id="your-azure-app-id",
-    client_credential="your-azure-client-secret",
-    authority="https://login.microsoftonline.com/your-tenant-id",
-)
+### Prerequisites
 
-def get_azure_token() -> str:
-    result = app.acquire_token_for_client(scopes=["api://your-albert-audience/.default"])
-    return result["id_token"]
+- An application registration with your identity provider, with the Albert API audience
+  registered by Albert support
+- A mechanism in your application to obtain an OIDC ID token from your provider
 
-client = Albert.from_sso_exchange(
-    base_url="https://mycompany.albertinvent.com",
-    subdomain="mycompany",
-    oidc_token_provider=get_azure_token,
-)
-```
+### Usage
 
-Or wire it up manually:
+Provide a callable that returns a fresh OIDC ID token on demand. The SDK calls it on the
+first request and again whenever the Albert access token needs to be renewed.
+
+=== "Microsoft Entra ID (Azure AD)"
+
+    ```python
+    from albert import Albert
+    from msal import ConfidentialClientApplication
+
+    app = ConfidentialClientApplication(
+        client_id="your-azure-app-id",
+        client_credential="your-azure-client-secret",
+        authority="https://login.microsoftonline.com/your-tenant-id",
+    )
+
+    def get_token() -> str:
+        result = app.acquire_token_for_client(scopes=["api://your-albert-audience/.default"])
+        return result["id_token"]
+
+    client = Albert.from_sso_exchange(
+        base_url="https://mycompany.albertinvent.com",
+        subdomain="mycompany",
+        oidc_token_provider=get_token,
+    )
+    ```
+
+=== "Okta"
+
+    ```python
+    from albert import Albert
+    import requests
+
+    def get_token() -> str:
+        resp = requests.post(
+            "https://mycompany.okta.com/oauth2/default/v1/token",
+            data={
+                "grant_type": "client_credentials",
+                "client_id": "your-okta-client-id",
+                "client_secret": "your-okta-client-secret",
+                "scope": "openid",
+            },
+        )
+        return resp.json()["id_token"]
+
+    client = Albert.from_sso_exchange(
+        base_url="https://mycompany.albertinvent.com",
+        subdomain="mycompany",
+        oidc_token_provider=get_token,
+    )
+    ```
+
+Or wire it up manually using `AlbertSSOTokenExchange` directly:
 
 ```python
+from albert import Albert, AlbertSSOTokenExchange
+
 auth = AlbertSSOTokenExchange(
     base_url="https://mycompany.albertinvent.com",
     subdomain="mycompany",
-    oidc_token_provider=get_azure_token,
+    oidc_token_provider=get_token,
 )
 client = Albert(auth_manager=auth)
 ```
 
 !!! warning "Use a callable, not a static token string"
-    Passing `lambda: "my-static-token"` works only while that Azure AD token remains valid
+    Passing `lambda: "my-static-token"` works only while that OIDC token remains valid
     (typically 60–90 minutes). Once it expires and the Albert access token needs renewal,
-    the exchange will fail. Always pass a callable that fetches a fresh token from Azure AD.
+    the exchange will fail. Always pass a callable that fetches a fresh token from your IdP.
 
 !!! warning "Token validity is your responsibility"
     The SDK passes the token returned by `oidc_token_provider` directly to Albert. If your
-    callable returns an expired or invalid Azure AD token, the exchange will fail with
-    `401 Unauthorized`. Ensure your token acquisition logic handles Azure AD token refresh.
+    callable returns an expired or invalid token, the exchange will fail with `401 Unauthorized`.
+    Ensure your token acquisition logic handles refresh appropriately.
 
 ---
 
diff --git a/docs/sso_exchange.md b/docs/sso_exchange.md
index 0f9e69189..49595602c 100644
--- a/docs/sso_exchange.md
+++ b/docs/sso_exchange.md
@@ -1,3 +1,3 @@
-# Azure AD SSO Token Exchange
+# OIDC Token Exchange
 
 ::: albert.AlbertSSOTokenExchange
diff --git a/mkdocs.yml b/mkdocs.yml
index 6103d2fda..943e1ab7c 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -131,7 +131,7 @@ nav:
       - Authentication:
           - Albert Client Credentials: credentials.md
           - Albert SSO Client: sso.md
-          - Azure AD SSO Token Exchange: sso_exchange.md
+          - OIDC Token Exchange: sso_exchange.md
       - Collections:
           - Activities: collections/activities.md
           - Attachments: collections/attachments.md
diff --git a/src/albert/client.py b/src/albert/client.py
index 2cc632af2..fb0566dc3 100644
--- a/src/albert/client.py
+++ b/src/albert/client.py
@@ -187,8 +187,8 @@ def from_sso_exchange(
         """Create an Albert client using server-to-server OIDC token exchange.
 
         Exchanges an OpenID Connect ID token for an Albert access token without
-        any browser interaction. Suitable for custom applications that already
-        authenticate users via their own identity provider (e.g. Okta, Azure AD).
+        any browser interaction. Works with any OIDC-compliant identity provider
+        that emits the ``preferred_username`` claim.
 
         Requires tenant-level OIDC configuration — the OpenID Connect ``aud`` claim
         must be registered with Albert for the target tenant.
@@ -202,7 +202,7 @@ def from_sso_exchange(
             The tenant subdomain (e.g. ``"mycompany"``).
         oidc_token_provider : Callable[[], str]
             A zero-argument callable that returns a fresh OIDC ID token on demand.
-            Called on the first request and again if the Albert refresh token expires.
+            Called on the first request and on every token renewal.
             A lambda returning a static string works for short-lived sessions.
         retries : int | None, optional
             Maximum number of retries for failed HTTP requests.
@@ -215,14 +215,14 @@ def from_sso_exchange(
         Examples
         --------
         ```python
-        def get_okta_token() -> str:
-            resp = requests.post("https://mycompany.okta.com/oauth2/token", data={...})
-            return resp.json()["id_token"]
+        def get_oidc_token() -> str:
+            # Acquire an ID token from your identity provider
+            ...
 
         client = Albert.from_sso_exchange(
             base_url="https://mycompany.albertinvent.com",
             subdomain="mycompany",
-            oidc_token_provider=get_okta_token,
+            oidc_token_provider=get_oidc_token,
         )
         ```
         """
@@ -574,6 +574,10 @@ def from_sso_exchange(
         """
         Create an AsyncAlbert client using server-to-server OIDC token exchange.
 
+        Works with any OIDC-compliant identity provider that emits the
+        ``preferred_username`` claim. Requires tenant-level OIDC configuration —
+        contact Albert support to enable.
+
         Parameters
         ----------
         base_url : str | None, optional
diff --git a/src/albert/core/auth/sso_exchange.py b/src/albert/core/auth/sso_exchange.py
index 7fcced9cb..95bbb5bd0 100644
--- a/src/albert/core/auth/sso_exchange.py
+++ b/src/albert/core/auth/sso_exchange.py
@@ -17,9 +17,13 @@ class AlbertSSOTokenExchange(BaseAlbertModel, AuthManager):
     """
     Auth manager for server-to-server OIDC token exchange with the Albert API.
 
-    Exchanges an OpenID Connect ID token for an Albert access token without
-    any browser interaction. Suitable for custom applications that already
-    authenticate users via their own OIDC identity provider (e.g. Okta, Azure AD).
+    Exchanges an OpenID Connect (OIDC) ID token for an Albert access token without
+    any browser interaction. Suitable for custom applications that already authenticate
+    users via an OIDC-compliant identity provider.
+
+    The identity provider must emit the ``preferred_username`` claim in the ID token,
+    which Albert uses to look up the corresponding user. Most enterprise IdPs include
+    this claim by default; see the authentication guide for provider-specific notes.
 
     Requires tenant-level OIDC configuration: the OpenID Connect ``aud`` claim
     must be registered with Albert for the target tenant. Contact Albert support
@@ -39,14 +43,14 @@ class AlbertSSOTokenExchange(BaseAlbertModel, AuthManager):
     Usage
     -----
     ```python
-    def get_okta_token() -> str:
-        resp = requests.post("https://mycompany.okta.com/oauth2/token", data={...})
-        return resp.json()["id_token"]
+    def get_oidc_token() -> str:
+        # Acquire an ID token from your identity provider
+        ...
 
     auth = AlbertSSOTokenExchange(
         base_url="https://mycompany.albertinvent.com",
         subdomain="mycompany",
-        oidc_token_provider=get_okta_token,
+        oidc_token_provider=get_oidc_token,
     )
     client = Albert(auth_manager=auth)
     ```

From 0b8c0134b239f8f03937a4d34edd13d4efa3c227 Mon Sep 17 00:00:00 2001
From: Prasad <prasad@albertinvent.com>
Date: Wed, 20 May 2026 12:07:08 +0530
Subject: [PATCH 3/4] fix(auth): address code review findings on
 AlbertSSOTokenExchange

---
 docs/authentication.md               | 69 ++++++++++------------------
 src/albert/core/auth/__init__.py     |  3 --
 src/albert/core/auth/sso_exchange.py | 18 +++++---
 3 files changed, 36 insertions(+), 54 deletions(-)

diff --git a/docs/authentication.md b/docs/authentication.md
index dc703ecfd..c24ac814e 100644
--- a/docs/authentication.md
+++ b/docs/authentication.md
@@ -95,59 +95,38 @@ token is supported. This includes:
 Provide a callable that returns a fresh OIDC ID token on demand. The SDK calls it on the
 first request and again whenever the Albert access token needs to be renewed.
 
-=== "Microsoft Entra ID (Azure AD)"
-
-    ```python
-    from albert import Albert
-    from msal import ConfidentialClientApplication
-
-    app = ConfidentialClientApplication(
-        client_id="your-azure-app-id",
-        client_credential="your-azure-client-secret",
-        authority="https://login.microsoftonline.com/your-tenant-id",
+```python
+from albert import Albert
+import requests
+
+def get_token() -> str:
+    resp = requests.post(
+        "https://mycompany.okta.com/oauth2/default/v1/token",
+        data={
+            "grant_type": "client_credentials",
+            "client_id": "your-okta-client-id",
+            "client_secret": "your-okta-client-secret",
+            "scope": "openid",
+        },
     )
+    return resp.json()["id_token"]
 
-    def get_token() -> str:
-        result = app.acquire_token_for_client(scopes=["api://your-albert-audience/.default"])
-        return result["id_token"]
-
-    client = Albert.from_sso_exchange(
-        base_url="https://mycompany.albertinvent.com",
-        subdomain="mycompany",
-        oidc_token_provider=get_token,
-    )
-    ```
-
-=== "Okta"
-
-    ```python
-    from albert import Albert
-    import requests
-
-    def get_token() -> str:
-        resp = requests.post(
-            "https://mycompany.okta.com/oauth2/default/v1/token",
-            data={
-                "grant_type": "client_credentials",
-                "client_id": "your-okta-client-id",
-                "client_secret": "your-okta-client-secret",
-                "scope": "openid",
-            },
-        )
-        return resp.json()["id_token"]
-
-    client = Albert.from_sso_exchange(
-        base_url="https://mycompany.albertinvent.com",
-        subdomain="mycompany",
-        oidc_token_provider=get_token,
-    )
-    ```
+client = Albert.from_sso_exchange(
+    base_url="https://mycompany.albertinvent.com",
+    subdomain="mycompany",
+    oidc_token_provider=get_token,
+)
+```
 
 Or wire it up manually using `AlbertSSOTokenExchange` directly:
 
 ```python
 from albert import Albert, AlbertSSOTokenExchange
 
+def get_token() -> str:
+    # Acquire an ID token from your identity provider
+    ...
+
 auth = AlbertSSOTokenExchange(
     base_url="https://mycompany.albertinvent.com",
     subdomain="mycompany",
diff --git a/src/albert/core/auth/__init__.py b/src/albert/core/auth/__init__.py
index 797a1a069..e69de29bb 100644
--- a/src/albert/core/auth/__init__.py
+++ b/src/albert/core/auth/__init__.py
@@ -1,3 +0,0 @@
-from albert.core.auth.sso_exchange import AlbertSSOTokenExchange
-
-__all__ = ["AlbertSSOTokenExchange"]
diff --git a/src/albert/core/auth/sso_exchange.py b/src/albert/core/auth/sso_exchange.py
index 95bbb5bd0..f2299e66f 100644
--- a/src/albert/core/auth/sso_exchange.py
+++ b/src/albert/core/auth/sso_exchange.py
@@ -9,7 +9,7 @@
 
 from albert.core.auth._manager import AuthManager, OAuthTokenInfo
 from albert.core.base import BaseAlbertModel
-from albert.exceptions import handle_http_errors
+from albert.exceptions import AlbertAuthError, handle_http_errors
 from albert.utils._auth import default_albert_base_url
 
 
@@ -69,15 +69,21 @@ def _exchange(self) -> None:
             response = requests.post(
                 self.exchange_url,
                 json={"jwt": self.oidc_token_provider(), "subdomain": self.subdomain},
+                timeout=30,
             )
             response.raise_for_status()
         data = response.json()
-        expires_in = data.get(
-            "expires_in", 3300
-        )  # fallback: 55 min until backend ships expires_in
+        access_token = data.get("jwt")
+        refresh_token = data.get("refreshtoken")
+        if not access_token or not refresh_token:
+            raise AlbertAuthError(
+                "SSO exchange failed: unexpected response from server. "
+                f"Expected 'jwt' and 'refreshtoken' fields, got: {list(data.keys())}"
+            )
+        expires_in = data.get("expires_in", 3300)  # fallback: 55 min until backend ships field
         self._token_info = OAuthTokenInfo(
-            access_token=data["jwt"],
-            refresh_token=data["refreshtoken"],
+            access_token=access_token,
+            refresh_token=refresh_token,
             expires_in=expires_in,
         )
         self._refresh_time = (

From 597b899d7be69bf905ef0790326c6fde828cdc80 Mon Sep 17 00:00:00 2001
From: Prasad <prasad@albertinvent.com>
Date: Wed, 20 May 2026 12:29:55 +0530
Subject: [PATCH 4/4] fix(auth): move response.json() inside handle_http_errors
 context

---
 src/albert/core/auth/sso_exchange.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/src/albert/core/auth/sso_exchange.py b/src/albert/core/auth/sso_exchange.py
index f2299e66f..bcf3855b2 100644
--- a/src/albert/core/auth/sso_exchange.py
+++ b/src/albert/core/auth/sso_exchange.py
@@ -72,7 +72,7 @@ def _exchange(self) -> None:
                 timeout=30,
             )
             response.raise_for_status()
-        data = response.json()
+            data = response.json()
         access_token = data.get("jwt")
         refresh_token = data.get("refreshtoken")
         if not access_token or not refresh_token:
