diff --git a/docs/authentication.md b/docs/authentication.md index 3b129600c..c24ac814e 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 +* **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) @@ -46,6 +47,106 @@ client = Albert.from_sso( --- +## 🔄 OIDC Token Exchange + +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 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. + +### Supported Identity Providers + +Any OIDC-compliant identity provider that includes the `preferred_username` claim in the ID +token is supported. This includes: + +| 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. | + +!!! 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. + +!!! info "SAML providers are not supported" + Identity providers configured to use SAML only (rather than OIDC) are not compatible with + this authentication method. + +### Prerequisites + +- 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 + +### Usage + +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. + +```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 + +def get_token() -> str: + # Acquire an ID token from your identity provider + ... + +auth = AlbertSSOTokenExchange( + base_url="https://mycompany.albertinvent.com", + subdomain="mycompany", + 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 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 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 token, the exchange will fail with `401 Unauthorized`. + Ensure your token acquisition logic handles refresh appropriately. + +--- + ## 🔑 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..49595602c --- /dev/null +++ b/docs/sso_exchange.md @@ -0,0 +1,3 @@ +# OIDC Token Exchange + +::: albert.AlbertSSOTokenExchange diff --git a/mkdocs.yml b/mkdocs.yml index d8101a2a5..943e1ab7c 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -131,6 +131,7 @@ nav: - Authentication: - Albert Client Credentials: credentials.md - Albert SSO Client: sso.md + - OIDC 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..fb0566dc3 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. 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. + + 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 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. + + Returns + ------- + Albert + A configured client that exchanges and refreshes tokens automatically. + + Examples + -------- + ```python + 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_oidc_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,44 @@ 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. + + 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 + 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/sso_exchange.py b/src/albert/core/auth/sso_exchange.py new file mode 100644 index 000000000..bcf3855b2 --- /dev/null +++ b/src/albert/core/auth/sso_exchange.py @@ -0,0 +1,97 @@ +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 AlbertAuthError, 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 (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 + 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_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_oidc_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}, + timeout=30, + ) + response.raise_for_status() + data = response.json() + 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=access_token, + refresh_token=refresh_token, + 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