From a2dd94f7a4001f49b1d9650669cf9223650a0a92 Mon Sep 17 00:00:00 2001 From: Prasad 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 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 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 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: