Add first-class Okta OIDC auth support

This commit is contained in:
Dennis Henry 2026-05-10 13:22:57 -04:00 committed by Dennis Henry
parent 8eecf76d36
commit 5a486d3336
No known key found for this signature in database
GPG key ID: 56B1C1BE1C615F34
24 changed files with 647 additions and 222 deletions

View file

@ -4440,6 +4440,18 @@ class LiteLLM_JWTAuth(LiteLLMPydanticObjectBase):
)
end_user_id_jwt_field: Optional[str] = None
public_key_ttl: float = 600
public_key_url: Optional[Union[str, List[str]]] = Field(
default=None,
description="JWKS URL or OIDC discovery URL used to validate JWTs. Falls back to JWT_PUBLIC_KEY_URL.",
)
audience: Optional[Union[str, List[str]]] = Field(
default=None,
description="Expected JWT aud claim. Falls back to JWT_AUDIENCE.",
)
issuer: Optional[Union[str, List[str]]] = Field(
default=None,
description="Expected JWT iss claim. Falls back to JWT_ISSUER.",
)
public_allowed_routes: List[str] = ["public_routes"]
enforce_rbac: bool = False
roles_jwt_field: Optional[str] = None # v2 on role mappings

View file

@ -916,11 +916,13 @@ def _has_user_setup_sso():
"""
microsoft_client_id = os.getenv("MICROSOFT_CLIENT_ID", None)
google_client_id = os.getenv("GOOGLE_CLIENT_ID", None)
okta_client_id = os.getenv("OKTA_CLIENT_ID", None)
generic_client_id = os.getenv("GENERIC_CLIENT_ID", None)
sso_setup = (
(microsoft_client_id is not None)
or (google_client_id is not None)
or (okta_client_id is not None)
or (generic_client_id is not None)
)

View file

@ -595,12 +595,17 @@ class JWTHandler:
return jwks_uri
async def get_public_key(self, kid: Optional[str]) -> dict:
keys_url = os.getenv("JWT_PUBLIC_KEY_URL")
keys_url = self.litellm_jwtauth.public_key_url or os.getenv(
"JWT_PUBLIC_KEY_URL"
)
if keys_url is None:
raise Exception("Missing JWT Public Key URL from environment.")
keys_url_list = [url.strip() for url in keys_url.split(",")]
if isinstance(keys_url, list):
keys_url_list = [url.strip() for url in keys_url]
else:
keys_url_list = [url.strip() for url in keys_url.split(",")]
for key_url in keys_url_list:
key_url = await self._resolve_jwks_url(key_url)
@ -745,7 +750,9 @@ class JWTHandler:
_unscoped_jwt_warning_emitted = False
@classmethod
def _build_decode_kwargs(cls) -> dict:
def _build_decode_kwargs(
cls, litellm_jwtauth: Optional[LiteLLM_JWTAuth] = None
) -> dict:
"""Build the audience/issuer/options kwargs for ``jwt.decode``.
Setting ``JWT_AUDIENCE`` (and optionally ``JWT_ISSUER``) turns on the
@ -754,8 +761,12 @@ class JWTHandler:
When both are unset PyJWT only checks the signature and expiry, which
is preserved for backward compatibility but logged once as a warning.
"""
audience = os.getenv("JWT_AUDIENCE")
issuer = os.getenv("JWT_ISSUER")
audience = (
litellm_jwtauth.audience if litellm_jwtauth is not None else None
) or os.getenv("JWT_AUDIENCE")
issuer = (
litellm_jwtauth.issuer if litellm_jwtauth is not None else None
) or os.getenv("JWT_ISSUER")
if (
audience is None
@ -783,7 +794,7 @@ class JWTHandler:
}
async def auth_jwt(self, token: str) -> dict:
decode_kwargs = self._build_decode_kwargs()
decode_kwargs = self._build_decode_kwargs(self.litellm_jwtauth)
header = jwt.get_unverified_header(token)

View file

@ -0,0 +1,85 @@
# Okta OIDC SSO + JWT Authentication Config
#
# This config covers both:
# 1. Admin UI login with Okta OIDC.
# 2. API authorization with Okta-issued id_tokens/access_tokens.
#
# Okta setup:
# 1. Create an Okta OIDC application:
# Applications -> Applications -> Create App Integration -> OIDC.
# 2. Use "Web Application" for confidential-client login with a client secret.
# 3. Add the LiteLLM redirect URI:
# https://<your-litellm-host>/sso/callback
# 4. Copy the Client ID, Client Secret, and Issuer.
# For a custom authorization server, issuer usually looks like:
# https://your-okta-domain.okta.com/oauth2/default
# 5. Optional: add a "groups" claim to the authorization server if you want
# LiteLLM team/role mapping from Okta groups.
#
# API authorization flow:
# 1. Your app obtains a JWT from Okta (e.g. id_token from the OIDC /token endpoint).
# 2. The caller passes it as: Authorization: Bearer <okta_token>
# 3. LiteLLM validates signature, audience, and issuer against Okta.
environment_variables:
# Admin UI SSO. LiteLLM derives /v1/authorize, /v1/token, and /v1/userinfo
# from OKTA_ISSUER unless you set explicit OKTA_*_ENDPOINT overrides.
OKTA_CLIENT_ID: "your-okta-client-id"
OKTA_CLIENT_SECRET: "your-okta-client-secret"
OKTA_ISSUER: "https://your-okta-domain.okta.com/oauth2/default"
OKTA_SCOPE: "openid email profile groups"
OKTA_CLIENT_USE_PKCE: "true"
model_list:
- model_name: gpt-4o
litellm_params:
model: openai/gpt-4o
api_key: os.environ/OPENAI_API_KEY
general_settings:
enable_jwt_auth: true
litellm_jwtauth:
# Okta OIDC discovery URL or JWKS URL. LiteLLM auto-resolves jwks_uri from
# the discovery document.
public_key_url: "https://your-okta-domain.okta.com/oauth2/default/.well-known/openid-configuration"
# --- Okta claim → LiteLLM field mappings ---
# "sub" is Okta's stable user identifier (always present)
user_id_jwt_field: "sub"
# "email" is present when the token includes the "email" scope
user_email_jwt_field: "email"
# Map Okta groups to LiteLLM teams.
# Requires the "groups" claim to be added to your Okta authorization server:
# Admin Console → Security → API → <your auth server> → Claims → Add Claim
# Name: groups, Include in: ID Token, Value type: Groups, Filter: Starts with <prefix>
team_ids_jwt_field: "groups"
# Validate the token audience. Set to your Okta application's Client ID
# or a custom audience string configured on your authorization server.
audience: "your-okta-client-id"
# Validate the token issuer. Must match the "iss" claim in the token exactly.
issuer: "https://your-okta-domain.okta.com/oauth2/default"
# --- Role mapping (optional) ---
# Map Okta group membership to LiteLLM RBAC roles.
# The "groups" claim must be enabled on your authorization server (see above).
roles_jwt_field: "groups"
role_mappings:
- role: "litellm-admins" # Okta group name
internal_role: "proxy_admin"
- role: "litellm-users"
internal_role: "internal_user"
# --- Auto-provisioning (optional) ---
# Create LiteLLM user/team records on first login if they don't exist yet.
user_id_upsert: true
team_id_upsert: true
# Enforce that every request carries a recognised RBAC role.
# Set to false while onboarding so unknown users still get through.
enforce_rbac: false

View file

@ -378,6 +378,29 @@ async def cli_sso_complete(request: Request, login_id: str):
return HTMLResponse(content=html_content, status_code=200)
_OIDC_PROVIDER_GENERIC: Literal["generic"] = "generic"
_OIDC_PROVIDER_OKTA: Literal["okta"] = "okta"
_OIDC_PROVIDER_NAMES = Literal["generic", "okta"]
def _get_oidc_env_prefix(provider: _OIDC_PROVIDER_NAMES) -> str:
return "OKTA" if provider == _OIDC_PROVIDER_OKTA else "GENERIC"
def _get_okta_endpoint_from_issuer(
okta_issuer: Optional[str], endpoint: str
) -> Optional[str]:
if not okta_issuer:
return None
return f"{okta_issuer.rstrip('/')}/v1/{endpoint}"
def _is_oidc_pkce_enabled(provider: _OIDC_PROVIDER_NAMES) -> bool:
env_prefix = _get_oidc_env_prefix(provider)
default_value = "true" if provider == _OIDC_PROVIDER_OKTA else "false"
return os.getenv(f"{env_prefix}_CLIENT_USE_PKCE", default_value).lower() == "true"
def normalize_email(email: Optional[str]) -> Optional[str]:
"""
Normalize email address to lowercase for consistent storage and comparison.
@ -592,6 +615,7 @@ async def google_login(
microsoft_client_id = os.getenv("MICROSOFT_CLIENT_ID", None)
google_client_id = os.getenv("GOOGLE_CLIENT_ID", None)
okta_client_id = os.getenv("OKTA_CLIENT_ID", None)
generic_client_id = os.getenv("GENERIC_CLIENT_ID", None)
####### Check if UI is disabled #######
@ -605,6 +629,7 @@ async def google_login(
if (
microsoft_client_id is not None
or google_client_id is not None
or okta_client_id is not None
or generic_client_id is not None
):
if premium_user is not True:
@ -613,7 +638,7 @@ async def google_login(
total_users = await prisma_client.db.litellm_usertable.count()
if total_users and total_users > 5:
raise ProxyException(
message="You must be a LiteLLM Enterprise user to use SSO for more than 5 users. If you have a license please set `LITELLM_LICENSE` in your env. If you want to obtain a license meet with us here: https://enterprise.litellm.ai/demo You are seeing this error message because You set one of `MICROSOFT_CLIENT_ID`, `GOOGLE_CLIENT_ID`, or `GENERIC_CLIENT_ID` in your env. Please unset this",
message="You must be a LiteLLM Enterprise user to use SSO for more than 5 users. If you have a license please set `LITELLM_LICENSE` in your env. If you want to obtain a license meet with us here: https://enterprise.litellm.ai/demo You are seeing this error message because You set one of `MICROSOFT_CLIENT_ID`, `GOOGLE_CLIENT_ID`, `OKTA_CLIENT_ID`, or `GENERIC_CLIENT_ID` in your env. Please unset this",
type=ProxyErrorTypes.auth_error,
param="premium_user",
code=status.HTTP_403_FORBIDDEN,
@ -667,6 +692,7 @@ async def google_login(
SSOAuthenticationHandler.should_use_sso_handler(
microsoft_client_id=microsoft_client_id,
google_client_id=google_client_id,
okta_client_id=okta_client_id,
generic_client_id=generic_client_id,
)
is True
@ -676,6 +702,7 @@ async def google_login(
redirect_url=redirect_url,
microsoft_client_id=microsoft_client_id,
google_client_id=google_client_id,
okta_client_id=okta_client_id,
generic_client_id=generic_client_id,
state=cli_state,
request=request,
@ -708,31 +735,55 @@ def generic_response_convertor(
sso_jwt_handler: Optional[JWTHandler] = None,
role_mappings: Optional["RoleMappings"] = None,
team_mappings: Optional["TeamMappings"] = None,
provider: _OIDC_PROVIDER_NAMES = _OIDC_PROVIDER_GENERIC,
) -> CustomOpenID:
env_prefix = _get_oidc_env_prefix(provider)
user_id_default = "sub" if provider == _OIDC_PROVIDER_OKTA else "preferred_username"
display_name_default = "name" if provider == _OIDC_PROVIDER_OKTA else "sub"
first_name_default = (
"given_name" if provider == _OIDC_PROVIDER_OKTA else "first_name"
)
last_name_default = (
"family_name" if provider == _OIDC_PROVIDER_OKTA else "last_name"
)
provider_default = "iss" if provider == _OIDC_PROVIDER_OKTA else "provider"
generic_user_id_attribute_name = os.getenv(
"GENERIC_USER_ID_ATTRIBUTE", "preferred_username"
f"{env_prefix}_USER_ID_ATTRIBUTE",
os.getenv("GENERIC_USER_ID_ATTRIBUTE", user_id_default),
)
generic_user_display_name_attribute_name = os.getenv(
"GENERIC_USER_DISPLAY_NAME_ATTRIBUTE", "sub"
f"{env_prefix}_USER_DISPLAY_NAME_ATTRIBUTE",
os.getenv("GENERIC_USER_DISPLAY_NAME_ATTRIBUTE", display_name_default),
)
generic_user_email_attribute_name = os.getenv(
"GENERIC_USER_EMAIL_ATTRIBUTE", "email"
f"{env_prefix}_USER_EMAIL_ATTRIBUTE",
os.getenv("GENERIC_USER_EMAIL_ATTRIBUTE", "email"),
)
generic_user_first_name_attribute_name = os.getenv(
"GENERIC_USER_FIRST_NAME_ATTRIBUTE", "first_name"
f"{env_prefix}_USER_FIRST_NAME_ATTRIBUTE",
os.getenv("GENERIC_USER_FIRST_NAME_ATTRIBUTE", first_name_default),
)
generic_user_last_name_attribute_name = os.getenv(
"GENERIC_USER_LAST_NAME_ATTRIBUTE", "last_name"
f"{env_prefix}_USER_LAST_NAME_ATTRIBUTE",
os.getenv("GENERIC_USER_LAST_NAME_ATTRIBUTE", last_name_default),
)
generic_provider_attribute_name = os.getenv(
"GENERIC_USER_PROVIDER_ATTRIBUTE", "provider"
f"{env_prefix}_USER_PROVIDER_ATTRIBUTE",
os.getenv("GENERIC_USER_PROVIDER_ATTRIBUTE", provider_default),
)
generic_user_role_attribute_name = os.getenv("GENERIC_USER_ROLE_ATTRIBUTE", "role")
generic_user_role_attribute_name = os.getenv(
f"{env_prefix}_USER_ROLE_ATTRIBUTE",
os.getenv("GENERIC_USER_ROLE_ATTRIBUTE", "role"),
)
generic_user_extra_attributes = os.getenv("GENERIC_USER_EXTRA_ATTRIBUTES", None)
generic_user_extra_attributes = os.getenv(
f"{env_prefix}_USER_EXTRA_ATTRIBUTES",
os.getenv("GENERIC_USER_EXTRA_ATTRIBUTES", None),
)
verbose_proxy_logger.debug(
f" generic_user_id_attribute_name: {generic_user_id_attribute_name}\n generic_user_email_attribute_name: {generic_user_email_attribute_name}"
@ -832,45 +883,66 @@ def generic_response_convertor(
def _setup_generic_sso_env_vars(
generic_client_id: str, redirect_url: str
) -> Tuple[str, List[str], str, str, str, bool]:
"""Setup and validate Generic SSO environment variables."""
generic_client_secret = os.getenv("GENERIC_CLIENT_SECRET", None)
generic_scope = os.getenv("GENERIC_SCOPE", "openid email profile").split(" ")
generic_authorization_endpoint = os.getenv("GENERIC_AUTHORIZATION_ENDPOINT", None)
generic_token_endpoint = os.getenv("GENERIC_TOKEN_ENDPOINT", None)
generic_userinfo_endpoint = os.getenv("GENERIC_USERINFO_ENDPOINT", None)
generic_client_id: str,
redirect_url: str,
provider: _OIDC_PROVIDER_NAMES = _OIDC_PROVIDER_GENERIC,
) -> Tuple[Optional[str], List[str], str, str, str, bool]:
"""Setup and validate Generic/Okta SSO environment variables."""
env_prefix = _get_oidc_env_prefix(provider)
provider_label = "Okta" if provider == _OIDC_PROVIDER_OKTA else "Generic"
default_scope = (
"openid email profile groups"
if provider == _OIDC_PROVIDER_OKTA
else "openid email profile"
)
generic_client_secret = os.getenv(f"{env_prefix}_CLIENT_SECRET", None)
generic_scope = os.getenv(f"{env_prefix}_SCOPE", default_scope).split(" ")
okta_issuer = (
os.getenv("OKTA_ISSUER", None) if provider == _OIDC_PROVIDER_OKTA else None
)
generic_authorization_endpoint = os.getenv(
f"{env_prefix}_AUTHORIZATION_ENDPOINT", None
) or _get_okta_endpoint_from_issuer(okta_issuer, "authorize")
generic_token_endpoint = os.getenv(
f"{env_prefix}_TOKEN_ENDPOINT", None
) or _get_okta_endpoint_from_issuer(okta_issuer, "token")
generic_userinfo_endpoint = os.getenv(
f"{env_prefix}_USERINFO_ENDPOINT", None
) or _get_okta_endpoint_from_issuer(okta_issuer, "userinfo")
generic_include_client_id = (
os.getenv("GENERIC_INCLUDE_CLIENT_ID", "false").lower() == "true"
os.getenv(f"{env_prefix}_INCLUDE_CLIENT_ID", "false").lower() == "true"
)
# Validate required environment variables
if generic_client_secret is None:
if generic_client_secret is None and not _is_oidc_pkce_enabled(provider):
raise ProxyException(
message="GENERIC_CLIENT_SECRET not set. Set it in .env file",
message=f"{env_prefix}_CLIENT_SECRET not set. Set it in .env file",
type=ProxyErrorTypes.auth_error,
param="GENERIC_CLIENT_SECRET",
param=f"{env_prefix}_CLIENT_SECRET",
code=status.HTTP_500_INTERNAL_SERVER_ERROR,
)
if generic_authorization_endpoint is None:
raise ProxyException(
message="GENERIC_AUTHORIZATION_ENDPOINT not set. Set it in .env file",
message=(
f"{env_prefix}_AUTHORIZATION_ENDPOINT not set. " f"Set it in .env file"
),
type=ProxyErrorTypes.auth_error,
param="GENERIC_AUTHORIZATION_ENDPOINT",
param=f"{env_prefix}_AUTHORIZATION_ENDPOINT",
code=status.HTTP_500_INTERNAL_SERVER_ERROR,
)
if generic_token_endpoint is None:
raise ProxyException(
message="GENERIC_TOKEN_ENDPOINT not set. Set it in .env file",
message=f"{env_prefix}_TOKEN_ENDPOINT not set. Set it in .env file",
type=ProxyErrorTypes.auth_error,
param="GENERIC_TOKEN_ENDPOINT",
param=f"{env_prefix}_TOKEN_ENDPOINT",
code=status.HTTP_500_INTERNAL_SERVER_ERROR,
)
if generic_userinfo_endpoint is None:
raise ProxyException(
message="GENERIC_USERINFO_ENDPOINT not set. Set it in .env file",
message=f"{env_prefix}_USERINFO_ENDPOINT not set. Set it in .env file",
type=ProxyErrorTypes.auth_error,
param="GENERIC_USERINFO_ENDPOINT",
param=f"{env_prefix}_USERINFO_ENDPOINT",
code=status.HTTP_500_INTERNAL_SERVER_ERROR,
)
@ -878,7 +950,8 @@ def _setup_generic_sso_env_vars(
f"authorization_endpoint: {generic_authorization_endpoint}\ntoken_endpoint: {generic_token_endpoint}\nuserinfo_endpoint: {generic_userinfo_endpoint}"
)
verbose_proxy_logger.debug(
f"GENERIC_REDIRECT_URI: {redirect_url}\nGENERIC_CLIENT_ID: {generic_client_id}\n"
f"{provider_label.upper()}_REDIRECT_URI: {redirect_url}\n"
f"{env_prefix}_CLIENT_ID: {generic_client_id}\n"
)
return (
@ -1003,9 +1076,11 @@ async def _setup_role_mappings() -> Optional["RoleMappings"]:
return role_mappings
def _parse_generic_sso_headers() -> dict:
"""Parse comma-separated GENERIC_SSO_HEADERS env var into a dict."""
raw = os.getenv("GENERIC_SSO_HEADERS", None)
def _parse_generic_sso_headers(
provider: _OIDC_PROVIDER_NAMES = _OIDC_PROVIDER_GENERIC,
) -> dict:
"""Parse comma-separated *_SSO_HEADERS env var into a dict."""
raw = os.getenv(f"{_get_oidc_env_prefix(provider)}_SSO_HEADERS", None)
if raw is None:
return {}
result: Dict[str, str] = {}
@ -1022,6 +1097,7 @@ def _handle_generic_sso_error(
generic_authorization_endpoint: Optional[str],
generic_token_endpoint: Optional[str],
additional_headers: dict,
provider: _OIDC_PROVIDER_NAMES = _OIDC_PROVIDER_GENERIC,
) -> NoReturn:
"""Handle errors from generic SSO verify_and_process. Always re-raises."""
error_message = str(e)
@ -1029,7 +1105,9 @@ def _handle_generic_sso_error(
# Surface a helpful PKCE misconfiguration hint only when:
# 1. The error mentions PKCE/code verifier, AND
# 2. PKCE is not currently configured (GENERIC_CLIENT_USE_PKCE != true)
pkce_configured = os.getenv("GENERIC_CLIENT_USE_PKCE", "false").lower() == "true"
env_prefix = _get_oidc_env_prefix(provider)
pkce_env_var = f"{env_prefix}_CLIENT_USE_PKCE"
pkce_configured = _is_oidc_pkce_enabled(provider)
if not pkce_configured and (
"PKCE" in error_message or "code verifier" in error_message.lower()
):
@ -1037,26 +1115,30 @@ def _handle_generic_sso_error(
generic_authorization_endpoint
and "okta" in generic_authorization_endpoint.lower()
) or (generic_token_endpoint and "okta" in generic_token_endpoint.lower())
provider_name = "Okta" if is_okta else "Your OAuth provider"
provider_name = (
"Okta"
if provider == _OIDC_PROVIDER_OKTA or is_okta
else "Your OAuth provider"
)
detailed_message = (
f"SSO authentication failed: {provider_name} requires PKCE (Proof Key for Code Exchange) "
f"but it's not enabled in your LiteLLM configuration.\n\n"
f"SOLUTION: Add this environment variable and restart your proxy:\n"
f" GENERIC_CLIENT_USE_PKCE=true\n\n"
f" {pkce_env_var}=true\n\n"
)
if is_okta:
if provider == _OIDC_PROVIDER_OKTA or is_okta:
detailed_message += (
"For AWS ECS: Add the environment variable to your task definition.\n"
"For Docker: Add -e GENERIC_CLIENT_USE_PKCE=true to your docker run command.\n"
"For .env file: Add GENERIC_CLIENT_USE_PKCE=true to your .env file.\n\n"
f"For Docker: Add -e {pkce_env_var}=true to your docker run command.\n"
f"For .env file: Add {pkce_env_var}=true to your .env file.\n\n"
)
detailed_message += f"Original error: {error_message}"
raise ProxyException(
message=detailed_message,
type=ProxyErrorTypes.auth_error,
param="GENERIC_CLIENT_USE_PKCE",
param=pkce_env_var,
code=status.HTTP_401_UNAUTHORIZED,
)
@ -1083,6 +1165,7 @@ async def get_generic_sso_response(
], # sso specific jwt handler - used for restricted sso group access control
generic_client_id: str,
redirect_url: str,
provider: _OIDC_PROVIDER_NAMES = _OIDC_PROVIDER_GENERIC,
) -> Tuple[
Union[OpenID, dict], Optional[dict], Optional[dict]
]: # (result, received_response, access_token_payload)
@ -1100,7 +1183,11 @@ async def get_generic_sso_response(
generic_token_endpoint,
generic_userinfo_endpoint,
generic_include_client_id,
) = _setup_generic_sso_env_vars(generic_client_id, redirect_url)
) = _setup_generic_sso_env_vars(
generic_client_id=generic_client_id,
redirect_url=redirect_url,
provider=provider,
)
discovery = DiscoveryDocument(
authorization_endpoint=generic_authorization_endpoint,
@ -1120,6 +1207,7 @@ async def get_generic_sso_response(
sso_jwt_handler=sso_jwt_handler,
role_mappings=role_mappings,
team_mappings=team_mappings,
provider=provider,
)
SSOProvider = create_provider(
@ -1135,7 +1223,7 @@ async def get_generic_sso_response(
scope=generic_scope,
)
verbose_proxy_logger.debug("calling generic_sso.verify_and_process")
additional_generic_sso_headers_dict = _parse_generic_sso_headers()
additional_generic_sso_headers_dict = _parse_generic_sso_headers(provider)
code_verifier: Optional[str] = (
None # assigned inside try; initialized for type tracking
@ -1147,6 +1235,7 @@ async def get_generic_sso_response(
await SSOAuthenticationHandler.prepare_token_exchange_parameters(
request=request,
generic_include_client_id=generic_include_client_id,
provider=provider,
)
)
@ -1192,17 +1281,19 @@ async def get_generic_sso_response(
code=status.HTTP_400_BAD_REQUEST,
)
if not generic_client_id:
client_id_env = f"{_get_oidc_env_prefix(provider)}_CLIENT_ID"
raise ProxyException(
message="GENERIC_CLIENT_ID must be set when PKCE is enabled",
message=f"{client_id_env} must be set when PKCE is enabled",
type=ProxyErrorTypes.auth_error,
param="GENERIC_CLIENT_ID",
param=client_id_env,
code=status.HTTP_401_UNAUTHORIZED,
)
if not generic_token_endpoint:
token_endpoint_env = f"{_get_oidc_env_prefix(provider)}_TOKEN_ENDPOINT"
raise ProxyException(
message="GENERIC_TOKEN_ENDPOINT must be set when PKCE is enabled",
message=(f"{token_endpoint_env} must be set when PKCE is enabled"),
type=ProxyErrorTypes.auth_error,
param="GENERIC_TOKEN_ENDPOINT",
param=token_endpoint_env,
code=status.HTTP_401_UNAUTHORIZED,
)
# All guards above raise, so authorization_code is a non-empty str here.
@ -1267,6 +1358,7 @@ async def get_generic_sso_response(
generic_authorization_endpoint,
generic_token_endpoint,
additional_generic_sso_headers_dict,
provider,
)
verbose_proxy_logger.debug("generic result: %s", result)
return result or {}, received_response, access_token_payload
@ -1619,6 +1711,7 @@ async def auth_callback(request: Request, state: Optional[str] = None): # noqa:
microsoft_client_id = os.getenv("MICROSOFT_CLIENT_ID", None)
google_client_id = os.getenv("GOOGLE_CLIENT_ID", None)
okta_client_id = os.getenv("OKTA_CLIENT_ID", None)
generic_client_id = os.getenv("GENERIC_CLIENT_ID", None)
received_response: Optional[dict] = None
access_token_payload: Optional[dict] = None
@ -1649,6 +1742,20 @@ async def auth_callback(request: Request, state: Optional[str] = None): # noqa:
redirect_url=redirect_url,
)
elif okta_client_id is not None:
(
result,
received_response,
access_token_payload,
) = await get_generic_sso_response(
request=request,
jwt_handler=jwt_handler,
generic_client_id=okta_client_id,
redirect_url=redirect_url,
sso_jwt_handler=sso_jwt_handler,
provider=_OIDC_PROVIDER_OKTA,
)
elif generic_client_id is not None:
(
result,
@ -1684,7 +1791,7 @@ async def auth_callback(request: Request, state: Optional[str] = None): # noqa:
result=result,
request=request,
received_response=received_response,
generic_client_id=generic_client_id,
generic_client_id=generic_client_id or okta_client_id,
ui_access_mode=ui_access_mode,
access_token_payload=access_token_payload,
jwt_handler=jwt_handler,
@ -2056,6 +2163,7 @@ async def sso_readiness():
"""
microsoft_client_id = os.getenv("MICROSOFT_CLIENT_ID", None)
google_client_id = os.getenv("GOOGLE_CLIENT_ID", None)
okta_client_id = os.getenv("OKTA_CLIENT_ID", None)
generic_client_id = os.getenv("GENERIC_CLIENT_ID", None)
# Determine which SSO provider is configured
@ -2064,6 +2172,8 @@ async def sso_readiness():
configured_provider = "google"
elif microsoft_client_id is not None:
configured_provider = "microsoft"
elif okta_client_id is not None:
configured_provider = "okta"
elif generic_client_id is not None:
configured_provider = "generic"
@ -2091,6 +2201,23 @@ async def sso_readiness():
if microsoft_tenant is None:
missing_vars.append("MICROSOFT_TENANT")
elif configured_provider == "okta":
okta_client_secret = os.getenv("OKTA_CLIENT_SECRET", None)
okta_issuer = os.getenv("OKTA_ISSUER", None)
okta_authorization_endpoint = os.getenv("OKTA_AUTHORIZATION_ENDPOINT", None)
okta_token_endpoint = os.getenv("OKTA_TOKEN_ENDPOINT", None)
okta_userinfo_endpoint = os.getenv("OKTA_USERINFO_ENDPOINT", None)
if okta_client_secret is None and not _is_oidc_pkce_enabled(
_OIDC_PROVIDER_OKTA
):
missing_vars.append("OKTA_CLIENT_SECRET")
if okta_issuer is None and okta_authorization_endpoint is None:
missing_vars.append("OKTA_ISSUER or OKTA_AUTHORIZATION_ENDPOINT")
if okta_issuer is None and okta_token_endpoint is None:
missing_vars.append("OKTA_ISSUER or OKTA_TOKEN_ENDPOINT")
if okta_issuer is None and okta_userinfo_endpoint is None:
missing_vars.append("OKTA_ISSUER or OKTA_USERINFO_ENDPOINT")
elif configured_provider == "generic":
generic_client_secret = os.getenv("GENERIC_CLIENT_SECRET", None)
generic_authorization_endpoint = os.getenv(
@ -2098,7 +2225,9 @@ async def sso_readiness():
)
generic_token_endpoint = os.getenv("GENERIC_TOKEN_ENDPOINT", None)
generic_userinfo_endpoint = os.getenv("GENERIC_USERINFO_ENDPOINT", None)
if generic_client_secret is None:
if generic_client_secret is None and not _is_oidc_pkce_enabled(
_OIDC_PROVIDER_GENERIC
):
missing_vars.append("GENERIC_CLIENT_SECRET")
if generic_authorization_endpoint is None:
missing_vars.append("GENERIC_AUTHORIZATION_ENDPOINT")
@ -2170,6 +2299,7 @@ class SSOAuthenticationHandler:
redirect_url: str,
google_client_id: Optional[str] = None,
microsoft_client_id: Optional[str] = None,
okta_client_id: Optional[str] = None,
generic_client_id: Optional[str] = None,
state: Optional[str] = None,
request: Optional[Request] = None,
@ -2181,6 +2311,7 @@ class SSOAuthenticationHandler:
redirect_url (str): The URL to redirect the user to after login
google_client_id (Optional[str], optional): The Google Client ID. Defaults to None.
microsoft_client_id (Optional[str], optional): The Microsoft Client ID. Defaults to None.
okta_client_id (Optional[str], optional): The Okta Client ID. Defaults to None.
generic_client_id (Optional[str], optional): The Generic Client ID. Defaults to None.
request: Optional FastAPI request, used to drive the ``Secure``
attribute on the ``litellm_oauth_state`` CSRF cookie.
@ -2230,52 +2361,30 @@ class SSOAuthenticationHandler:
)
with microsoft_sso:
return await microsoft_sso.get_login_redirect(state=state)
elif generic_client_id is not None:
elif okta_client_id is not None or generic_client_id is not None:
from fastapi_sso.sso.base import DiscoveryDocument
from fastapi_sso.sso.generic import create_provider
generic_client_secret = os.getenv("GENERIC_CLIENT_SECRET", None)
generic_scope = os.getenv("GENERIC_SCOPE", "openid email profile").split(
" "
oidc_provider: _OIDC_PROVIDER_NAMES = (
_OIDC_PROVIDER_OKTA
if okta_client_id is not None
else _OIDC_PROVIDER_GENERIC
)
generic_authorization_endpoint = os.getenv(
"GENERIC_AUTHORIZATION_ENDPOINT", None
)
generic_token_endpoint = os.getenv("GENERIC_TOKEN_ENDPOINT", None)
generic_userinfo_endpoint = os.getenv("GENERIC_USERINFO_ENDPOINT", None)
if generic_client_secret is None:
raise ProxyException(
message="GENERIC_CLIENT_SECRET not set. Set it in .env file",
type=ProxyErrorTypes.auth_error,
param="GENERIC_CLIENT_SECRET",
code=status.HTTP_500_INTERNAL_SERVER_ERROR,
)
if generic_authorization_endpoint is None:
raise ProxyException(
message="GENERIC_AUTHORIZATION_ENDPOINT not set. Set it in .env file",
type=ProxyErrorTypes.auth_error,
param="GENERIC_AUTHORIZATION_ENDPOINT",
code=status.HTTP_500_INTERNAL_SERVER_ERROR,
)
if generic_token_endpoint is None:
raise ProxyException(
message="GENERIC_TOKEN_ENDPOINT not set. Set it in .env file",
type=ProxyErrorTypes.auth_error,
param="GENERIC_TOKEN_ENDPOINT",
code=status.HTTP_500_INTERNAL_SERVER_ERROR,
)
if generic_userinfo_endpoint is None:
raise ProxyException(
message="GENERIC_USERINFO_ENDPOINT not set. Set it in .env file",
type=ProxyErrorTypes.auth_error,
param="GENERIC_USERINFO_ENDPOINT",
code=status.HTTP_500_INTERNAL_SERVER_ERROR,
)
verbose_proxy_logger.debug(
f"authorization_endpoint: {generic_authorization_endpoint}\ntoken_endpoint: {generic_token_endpoint}\nuserinfo_endpoint: {generic_userinfo_endpoint}"
)
verbose_proxy_logger.debug(
f"GENERIC_REDIRECT_URI: {redirect_url}\nGENERIC_CLIENT_ID: {generic_client_id}\n"
oidc_client_id = okta_client_id or generic_client_id
if oidc_client_id is None:
raise ValueError("OIDC client ID is required for SSO login redirect")
(
generic_client_secret,
generic_scope,
generic_authorization_endpoint,
generic_token_endpoint,
generic_userinfo_endpoint,
_,
) = _setup_generic_sso_env_vars(
generic_client_id=oidc_client_id,
redirect_url=redirect_url,
provider=oidc_provider,
)
discovery = DiscoveryDocument(
authorization_endpoint=generic_authorization_endpoint,
@ -2284,7 +2393,7 @@ class SSOAuthenticationHandler:
)
SSOProvider = create_provider(name="oidc", discovery_document=discovery)
generic_sso = SSOProvider(
client_id=generic_client_id,
client_id=oidc_client_id,
client_secret=generic_client_secret,
redirect_uri=redirect_url,
allow_insecure_http=True,
@ -2295,6 +2404,7 @@ class SSOAuthenticationHandler:
state=state,
generic_authorization_endpoint=generic_authorization_endpoint,
request=request,
provider=oidc_provider,
)
raise ValueError(
"Unknown SSO provider. Please setup SSO with client IDs https://docs.litellm.ai/docs/proxy/admin_ui_sso"
@ -2306,6 +2416,7 @@ class SSOAuthenticationHandler:
state: Optional[str] = None,
generic_authorization_endpoint: Optional[str] = None,
request: Optional[Request] = None,
provider: _OIDC_PROVIDER_NAMES = _OIDC_PROVIDER_GENERIC,
) -> Optional[RedirectResponse]:
"""
Get the redirect response for Generic SSO
@ -2328,6 +2439,7 @@ class SSOAuthenticationHandler:
) = SSOAuthenticationHandler._get_generic_sso_redirect_params(
state=state,
generic_authorization_endpoint=generic_authorization_endpoint,
provider=provider,
)
# Separate PKCE params from state params (fastapi-sso doesn't accept code_challenge)
@ -2419,6 +2531,7 @@ class SSOAuthenticationHandler:
def _get_generic_sso_redirect_params(
state: Optional[str] = None,
generic_authorization_endpoint: Optional[str] = None,
provider: _OIDC_PROVIDER_NAMES = _OIDC_PROVIDER_GENERIC,
) -> Tuple[dict, Optional[str]]:
"""
Get redirect parameters for Generic SSO with proper state priority handling.
@ -2453,9 +2566,8 @@ class SSOAuthenticationHandler:
else:
redirect_params["state"] = uuid.uuid4().hex
# Handle PKCE (Proof Key for Code Exchange) if enabled
# Set GENERIC_CLIENT_USE_PKCE=true to enable PKCE for enhanced OAuth security
use_pkce = os.getenv("GENERIC_CLIENT_USE_PKCE", "false").lower() == "true"
# Handle PKCE (Proof Key for Code Exchange) if enabled.
use_pkce = _is_oidc_pkce_enabled(provider)
if use_pkce:
(
@ -2472,11 +2584,13 @@ class SSOAuthenticationHandler:
def should_use_sso_handler(
google_client_id: Optional[str] = None,
microsoft_client_id: Optional[str] = None,
okta_client_id: Optional[str] = None,
generic_client_id: Optional[str] = None,
) -> bool:
if (
google_client_id is not None
or microsoft_client_id is not None
or okta_client_id is not None
or generic_client_id is not None
):
return True
@ -3026,6 +3140,7 @@ class SSOAuthenticationHandler:
async def prepare_token_exchange_parameters(
request: Request,
generic_include_client_id: bool,
provider: _OIDC_PROVIDER_NAMES = _OIDC_PROVIDER_GENERIC,
) -> dict:
"""
Prepare token exchange parameters for Generic SSO.
@ -3041,20 +3156,22 @@ class SSOAuthenticationHandler:
token_params: Dict[str, Any] = {"include_client_id": generic_include_client_id}
# Retrieve PKCE code_verifier if PKCE was used in authorization.
# Gate on GENERIC_CLIENT_USE_PKCE to avoid an unnecessary Redis round-trip
# Gate on provider-specific PKCE config to avoid an unnecessary Redis round-trip
# on every non-PKCE SSO callback.
query_params = dict(request.query_params)
state = query_params.get("state")
use_pkce = os.getenv("GENERIC_CLIENT_USE_PKCE", "false").lower() == "true"
use_pkce = _is_oidc_pkce_enabled(provider)
if use_pkce and not state:
pkce_env_var = f"{_get_oidc_env_prefix(provider)}_CLIENT_USE_PKCE"
verbose_proxy_logger.warning(
"PKCE is enabled (GENERIC_CLIENT_USE_PKCE=true) but no 'state' parameter "
"PKCE is enabled (%s=true) but no 'state' parameter "
"was found in the callback. The PKCE verifier cannot be retrieved without "
"a state value — the token exchange will proceed without code_verifier, "
"which the provider may reject. Ensure your OAuth provider returns 'state' "
"in the callback redirect."
"in the callback redirect.",
pkce_env_var,
)
if state and use_pkce:
@ -3994,17 +4111,19 @@ async def debug_sso_login(request: Request):
microsoft_client_id = os.getenv("MICROSOFT_CLIENT_ID", None)
google_client_id = os.getenv("GOOGLE_CLIENT_ID", None)
okta_client_id = os.getenv("OKTA_CLIENT_ID", None)
generic_client_id = os.getenv("GENERIC_CLIENT_ID", None)
####### Check if user is a Enterprise / Premium User #######
if (
microsoft_client_id is not None
or google_client_id is not None
or okta_client_id is not None
or generic_client_id is not None
):
if premium_user is not True:
raise ProxyException(
message="You must be a LiteLLM Enterprise user to use SSO. If you have a license please set `LITELLM_LICENSE` in your env. If you want to obtain a license meet with us here: https://enterprise.litellm.ai/demo You are seeing this error message because You set one of `MICROSOFT_CLIENT_ID`, `GOOGLE_CLIENT_ID`, or `GENERIC_CLIENT_ID` in your env. Please unset this",
message="You must be a LiteLLM Enterprise user to use SSO. If you have a license please set `LITELLM_LICENSE` in your env. If you want to obtain a license meet with us here: https://enterprise.litellm.ai/demo You are seeing this error message because You set one of `MICROSOFT_CLIENT_ID`, `GOOGLE_CLIENT_ID`, `OKTA_CLIENT_ID`, or `GENERIC_CLIENT_ID` in your env. Please unset this",
type=ProxyErrorTypes.auth_error,
param="premium_user",
code=status.HTTP_403_FORBIDDEN,
@ -4021,6 +4140,7 @@ async def debug_sso_login(request: Request):
SSOAuthenticationHandler.should_use_sso_handler(
microsoft_client_id=microsoft_client_id,
google_client_id=google_client_id,
okta_client_id=okta_client_id,
generic_client_id=generic_client_id,
)
is True
@ -4029,6 +4149,7 @@ async def debug_sso_login(request: Request):
redirect_url=redirect_url,
microsoft_client_id=microsoft_client_id,
google_client_id=google_client_id,
okta_client_id=okta_client_id,
generic_client_id=generic_client_id,
request=request,
)
@ -4069,6 +4190,7 @@ async def debug_sso_callback(request: Request):
microsoft_client_id = os.getenv("MICROSOFT_CLIENT_ID", None)
google_client_id = os.getenv("GOOGLE_CLIENT_ID", None)
okta_client_id = os.getenv("OKTA_CLIENT_ID", None)
generic_client_id = os.getenv("GENERIC_CLIENT_ID", None)
redirect_url = os.getenv("PROXY_BASE_URL", str(request.base_url))
@ -4095,6 +4217,16 @@ async def debug_sso_callback(request: Request):
return_raw_sso_response=True,
)
elif okta_client_id is not None:
result, _, _ = await get_generic_sso_response(
request=request,
jwt_handler=jwt_handler,
generic_client_id=okta_client_id,
redirect_url=redirect_url,
sso_jwt_handler=sso_jwt_handler,
provider=_OIDC_PROVIDER_OKTA,
)
elif generic_client_id is not None:
result, received_response, access_token_payload = (
await get_generic_sso_response(

View file

@ -703,6 +703,18 @@ async def get_sso_settings():
"microsoft_client_secret", None
),
microsoft_tenant=decrypted_sso_settings_dict.get("microsoft_tenant", None),
okta_client_id=decrypted_sso_settings_dict.get("okta_client_id", None),
okta_client_secret=decrypted_sso_settings_dict.get("okta_client_secret", None),
okta_issuer=decrypted_sso_settings_dict.get("okta_issuer", None),
okta_authorization_endpoint=decrypted_sso_settings_dict.get(
"okta_authorization_endpoint", None
),
okta_token_endpoint=decrypted_sso_settings_dict.get(
"okta_token_endpoint", None
),
okta_userinfo_endpoint=decrypted_sso_settings_dict.get(
"okta_userinfo_endpoint", None
),
generic_client_id=decrypted_sso_settings_dict.get("generic_client_id", None),
generic_client_secret=decrypted_sso_settings_dict.get(
"generic_client_secret", None
@ -789,6 +801,12 @@ async def update_sso_settings(sso_config: SSOConfig):
"microsoft_client_id": "MICROSOFT_CLIENT_ID",
"microsoft_client_secret": "MICROSOFT_CLIENT_SECRET",
"microsoft_tenant": "MICROSOFT_TENANT",
"okta_client_id": "OKTA_CLIENT_ID",
"okta_client_secret": "OKTA_CLIENT_SECRET",
"okta_issuer": "OKTA_ISSUER",
"okta_authorization_endpoint": "OKTA_AUTHORIZATION_ENDPOINT",
"okta_token_endpoint": "OKTA_TOKEN_ENDPOINT",
"okta_userinfo_endpoint": "OKTA_USERINFO_ENDPOINT",
"generic_client_id": "GENERIC_CLIENT_ID",
"generic_client_secret": "GENERIC_CLIENT_SECRET",
"generic_authorization_endpoint": "GENERIC_AUTHORIZATION_ENDPOINT",
@ -814,7 +832,7 @@ async def update_sso_settings(sso_config: SSOConfig):
if field_name in env_var_mapping:
env_var_name = env_var_mapping[field_name]
if value:
os.environ[env_var_name] = value
os.environ[env_var_name] = str(value)
else:
# Clear environment variable if value is null/empty
os.environ.pop(env_var_name, None)

View file

@ -129,7 +129,33 @@ class SSOConfig(LiteLLMPydanticObjectBase):
description="Microsoft Azure Tenant ID for SSO authentication",
)
# Generic/Okta SSO
# Okta SSO
okta_client_id: Optional[str] = Field(
default=None,
description="Okta OAuth Client ID for SSO authentication",
)
okta_client_secret: Optional[str] = Field(
default=None,
description="Okta OAuth Client Secret for SSO authentication",
)
okta_issuer: Optional[str] = Field(
default=None,
description="Okta authorization server issuer URL, e.g. https://your-domain.okta.com/oauth2/default",
)
okta_authorization_endpoint: Optional[str] = Field(
default=None,
description="Optional Okta authorization endpoint override",
)
okta_token_endpoint: Optional[str] = Field(
default=None,
description="Optional Okta token endpoint override",
)
okta_userinfo_endpoint: Optional[str] = Field(
default=None,
description="Optional Okta userinfo endpoint override",
)
# Generic SSO
generic_client_id: Optional[str] = Field(
default=None,
description="Generic OAuth Client ID for SSO authentication (used for Okta and other providers)",

View file

@ -2185,6 +2185,85 @@ async def test_resolve_jwks_url_raises_if_no_jwks_uri_in_discovery_doc():
await handler._resolve_jwks_url(discovery_url)
@pytest.mark.asyncio
async def test_get_public_key_uses_litellm_jwtauth_public_key_url(monkeypatch):
"""JWT public key URL can be configured in litellm_jwtauth, not only env."""
from unittest.mock import AsyncMock, MagicMock
from litellm.caching.dual_cache import DualCache
monkeypatch.delenv("JWT_PUBLIC_KEY_URL", raising=False)
handler = JWTHandler()
jwks_url = "https://example.okta.com/oauth2/default/v1/keys"
handler.update_environment(
prisma_client=None,
user_api_key_cache=DualCache(),
litellm_jwtauth=LiteLLM_JWTAuth(public_key_url=jwks_url),
)
mock_response = MagicMock()
mock_response.json.return_value = {
"keys": [{"kid": "okta-key", "kty": "RSA", "n": "abc", "e": "AQAB"}]
}
handler.http_handler.get = AsyncMock(return_value=mock_response)
public_key = await handler.get_public_key(kid="okta-key")
assert public_key["kid"] == "okta-key"
handler.http_handler.get.assert_called_once_with(jwks_url)
@pytest.mark.asyncio
async def test_auth_jwt_uses_litellm_jwtauth_audience_and_issuer(monkeypatch):
"""Okta id_tokens should validate aud/iss from litellm_jwtauth config."""
from unittest.mock import AsyncMock, MagicMock
import jwt as pyjwt
from litellm.caching.dual_cache import DualCache
monkeypatch.delenv("JWT_AUDIENCE", raising=False)
monkeypatch.delenv("JWT_ISSUER", raising=False)
handler = JWTHandler()
handler.update_environment(
prisma_client=None,
user_api_key_cache=DualCache(),
litellm_jwtauth=LiteLLM_JWTAuth(
public_key_url="https://example.okta.com/oauth2/default/v1/keys",
audience="okta-client-id",
issuer="https://example.okta.com/oauth2/default",
),
)
token = pyjwt.encode(
{"sub": "okta-user"},
"secret",
algorithm="HS256",
headers={"kid": "okta-key"},
)
with (
patch.object(
handler,
"get_public_key",
new_callable=AsyncMock,
return_value={"kid": "okta-key", "kty": "RSA", "n": "abc", "e": "AQAB"},
),
patch("litellm.proxy.auth.handle_jwt.PyJWK.from_dict") as mock_jwk_from_dict,
patch("litellm.proxy.auth.handle_jwt.jwt.decode") as mock_decode,
):
mock_jwk_from_dict.return_value = MagicMock(key="public-key")
mock_decode.return_value = {"sub": "okta-user"}
payload = await handler.auth_jwt(token)
assert payload == {"sub": "okta-user"}
decode_kwargs = mock_decode.call_args.kwargs
assert decode_kwargs["audience"] == "okta-client-id"
assert decode_kwargs["issuer"] == "https://example.okta.com/oauth2/default"
# ---------------------------------------------------------------------------
# Fix 2: handle array values in team_id_jwt_field (e.g. AAD "roles" claim)
# ---------------------------------------------------------------------------

View file

@ -1552,6 +1552,10 @@ class TestSSOHandlerIntegration:
SSOAuthenticationHandler.should_use_sso_handler(microsoft_client_id="test")
is True
)
assert (
SSOAuthenticationHandler.should_use_sso_handler(okta_client_id="test")
is True
)
assert (
SSOAuthenticationHandler.should_use_sso_handler(generic_client_id="test")
is True
@ -1563,6 +1567,68 @@ class TestSSOHandlerIntegration:
SSOAuthenticationHandler.should_use_sso_handler(None, None, None) is False
)
@patch.dict(
os.environ,
{
"OKTA_CLIENT_SECRET": "test-okta-secret",
"OKTA_ISSUER": "https://example.okta.com/oauth2/default",
},
clear=True,
)
def test_setup_okta_sso_env_vars_from_issuer(self):
"""Okta SSO derives standard OIDC endpoints from OKTA_ISSUER."""
from litellm.proxy.management_endpoints.ui_sso import (
_OIDC_PROVIDER_OKTA,
_setup_generic_sso_env_vars,
)
(
client_secret,
scope,
authorization_endpoint,
token_endpoint,
userinfo_endpoint,
include_client_id,
) = _setup_generic_sso_env_vars(
generic_client_id="okta-client-id",
redirect_url="https://litellm.example.com/sso/callback",
provider=_OIDC_PROVIDER_OKTA,
)
assert client_secret == "test-okta-secret"
assert scope == ["openid", "email", "profile", "groups"]
assert (
authorization_endpoint
== "https://example.okta.com/oauth2/default/v1/authorize"
)
assert token_endpoint == "https://example.okta.com/oauth2/default/v1/token"
assert (
userinfo_endpoint == "https://example.okta.com/oauth2/default/v1/userinfo"
)
assert include_client_id is False
def test_okta_pkce_enabled_by_default(self):
"""Okta SSO enables PKCE by default for a first-class OIDC flow."""
from litellm.proxy.management_endpoints.ui_sso import (
_OIDC_PROVIDER_OKTA,
SSOAuthenticationHandler,
)
with patch.dict(os.environ, {}, clear=True):
redirect_params, code_verifier = (
SSOAuthenticationHandler._get_generic_sso_redirect_params(
state="okta-state",
generic_authorization_endpoint=(
"https://example.okta.com/oauth2/default/v1/authorize"
),
provider=_OIDC_PROVIDER_OKTA,
)
)
assert redirect_params["state"] == "okta-state"
assert code_verifier is not None
assert redirect_params["code_challenge_method"] == "S256"
@patch.dict(os.environ, {}, clear=False)
def test_get_redirect_url_for_sso(self):
"""Test the redirect URL generation for SSO"""

View file

@ -1429,6 +1429,7 @@ class TestProxySettingEndpoints:
env_var_entry.param_value = json.dumps(
{
"GOOGLE_CLIENT_ID": "old_google_id",
"OKTA_CLIENT_ID": "old_okta_id",
"GENERIC_TOKEN_ENDPOINT": "old_endpoint",
"UNCHANGED_ENV": "keep_me",
}
@ -1458,6 +1459,7 @@ class TestProxySettingEndpoints:
update_call = mock_prisma.db.litellm_config.update.call_args
updated_env_vars = json.loads(update_call.kwargs["data"]["param_value"])
assert "GOOGLE_CLIENT_ID" not in updated_env_vars
assert "OKTA_CLIENT_ID" not in updated_env_vars
assert "GENERIC_TOKEN_ENDPOINT" not in updated_env_vars
assert updated_env_vars["UNCHANGED_ENV"] == "keep_me"

View file

@ -8,6 +8,12 @@ export interface EditSSOSettingsParams {
microsoft_client_id?: string | null;
microsoft_client_secret?: string | null;
microsoft_tenant?: string | null;
okta_client_id?: string | null;
okta_client_secret?: string | null;
okta_issuer?: string | null;
okta_authorization_endpoint?: string | null;
okta_token_endpoint?: string | null;
okta_userinfo_endpoint?: string | null;
generic_client_id?: string | null;
generic_client_secret?: string | null;
generic_authorization_endpoint?: string | null;

View file

@ -21,6 +21,12 @@ const mockSSOSettingsResponse: SSOSettingsResponse = {
microsoft_client_id: "test-microsoft-client-id",
microsoft_client_secret: "test-microsoft-client-secret",
microsoft_tenant: "test-tenant",
okta_client_id: "test-okta-client-id",
okta_client_secret: "test-okta-client-secret",
okta_issuer: "https://example.okta.com/oauth2/default",
okta_authorization_endpoint: null,
okta_token_endpoint: null,
okta_userinfo_endpoint: null,
generic_client_id: "test-generic-client-id",
generic_client_secret: "test-generic-client-secret",
generic_authorization_endpoint: "https://example.com/auth",
@ -239,6 +245,12 @@ describe("useSSOSettings", () => {
microsoft_client_id: null,
microsoft_client_secret: null,
microsoft_tenant: null,
okta_client_id: null,
okta_client_secret: null,
okta_issuer: null,
okta_authorization_endpoint: null,
okta_token_endpoint: null,
okta_userinfo_endpoint: null,
generic_client_id: null,
generic_client_secret: null,
generic_authorization_endpoint: null,

View file

@ -19,6 +19,12 @@ export interface SSOSettingsValues {
microsoft_client_id: string | null;
microsoft_client_secret: string | null;
microsoft_tenant: string | null;
okta_client_id: string | null;
okta_client_secret: string | null;
okta_issuer: string | null;
okta_authorization_endpoint: string | null;
okta_token_endpoint: string | null;
okta_userinfo_endpoint: string | null;
generic_client_id: string | null;
generic_client_secret: string | null;
generic_authorization_endpoint: string | null;

View file

@ -358,20 +358,14 @@ describe("SSOModals", () => {
fireEvent.change(urlInput, { target: { value: "https://example.com" } });
// Fill Okta specific fields
const clientIdInput = screen.getByLabelText("Generic Client ID");
const clientIdInput = screen.getByLabelText("Okta Client ID");
fireEvent.change(clientIdInput, { target: { value: "test-client-id" } });
const clientSecretInput = screen.getByLabelText("Generic Client Secret");
const clientSecretInput = screen.getByLabelText("Okta Client Secret");
fireEvent.change(clientSecretInput, { target: { value: "test-client-secret" } });
const authEndpointInput = screen.getByLabelText("Authorization Endpoint");
fireEvent.change(authEndpointInput, { target: { value: "https://example.okta.com/authorize" } });
const tokenEndpointInput = screen.getByLabelText("Token Endpoint");
fireEvent.change(tokenEndpointInput, { target: { value: "https://example.okta.com/token" } });
const userinfoEndpointInput = screen.getByLabelText("Userinfo Endpoint");
fireEvent.change(userinfoEndpointInput, { target: { value: "https://example.okta.com/userinfo" } });
const issuerInput = screen.getByLabelText("Okta Issuer");
fireEvent.change(issuerInput, { target: { value: "https://example.okta.com/oauth2/default" } });
// Fill role mapping fields
const groupClaimInput = screen.getByLabelText("Group Claim");
@ -390,13 +384,11 @@ describe("SSOModals", () => {
sso_provider: "okta",
user_email: "admin@example.com",
proxy_base_url: "https://example.com",
generic_client_id: "test-client-id",
generic_client_secret: "test-client-secret",
generic_authorization_endpoint: "https://example.okta.com/authorize",
generic_token_endpoint: "https://example.okta.com/token",
generic_userinfo_endpoint: "https://example.okta.com/userinfo",
okta_client_id: "test-client-id",
okta_client_secret: "test-client-secret",
okta_issuer: "https://example.okta.com/oauth2/default",
role_mappings: {
provider: "generic",
provider: "okta",
group_claim: "groups",
default_role: "internal_user",
roles: {
@ -457,6 +449,12 @@ describe("SSOModals", () => {
microsoft_client_id: null,
microsoft_client_secret: null,
microsoft_tenant: null,
okta_client_id: null,
okta_client_secret: null,
okta_issuer: null,
okta_authorization_endpoint: null,
okta_token_endpoint: null,
okta_userinfo_endpoint: null,
generic_client_id: null,
generic_client_secret: null,
generic_authorization_endpoint: null,

View file

@ -61,25 +61,17 @@ const ssoProviderConfigs: Record<string, SSOProviderConfig> = {
},
okta: {
envVarMap: {
generic_client_id: "GENERIC_CLIENT_ID",
generic_client_secret: "GENERIC_CLIENT_SECRET",
generic_authorization_endpoint: "GENERIC_AUTHORIZATION_ENDPOINT",
generic_token_endpoint: "GENERIC_TOKEN_ENDPOINT",
generic_userinfo_endpoint: "GENERIC_USERINFO_ENDPOINT",
okta_client_id: "OKTA_CLIENT_ID",
okta_client_secret: "OKTA_CLIENT_SECRET",
okta_issuer: "OKTA_ISSUER",
},
fields: [
{ label: "Generic Client ID", name: "generic_client_id" },
{ label: "Generic Client Secret", name: "generic_client_secret" },
{ label: "Okta Client ID", name: "okta_client_id" },
{ label: "Okta Client Secret", name: "okta_client_secret" },
{
label: "Authorization Endpoint",
name: "generic_authorization_endpoint",
placeholder: "https://your-domain/authorize",
},
{ label: "Token Endpoint", name: "generic_token_endpoint", placeholder: "https://your-domain/token" },
{
label: "Userinfo Endpoint",
name: "generic_userinfo_endpoint",
placeholder: "https://your-domain/userinfo",
label: "Okta Issuer",
name: "okta_issuer",
placeholder: "https://your-domain.okta.com/oauth2/default",
},
],
},
@ -121,23 +113,18 @@ const SSOModals: React.FC<SSOModalsProps> = ({
if (isAddSSOModalVisible && accessToken) {
try {
const ssoData = await getSSOSettings(accessToken);
console.log("Raw SSO data received:", ssoData); // Debug log
if (ssoData && ssoData.values) {
console.log("SSO values:", ssoData.values); // Debug log
console.log("user_email from API:", ssoData.values.user_email); // Debug log
// Determine which SSO provider is configured
let selectedProvider = null;
if (ssoData.values.google_client_id) {
selectedProvider = "google";
} else if (ssoData.values.microsoft_client_id) {
selectedProvider = "microsoft";
} else if (ssoData.values.okta_client_id) {
selectedProvider = "okta";
} else if (ssoData.values.generic_client_id) {
// Check if it looks like Okta based on endpoints
if (
ssoData.values.generic_authorization_endpoint?.includes("okta") ||
ssoData.values.generic_authorization_endpoint?.includes("auth0")
) {
// Backward compatibility: older Okta UI settings were stored as generic OIDC endpoints.
if (ssoData.values.generic_authorization_endpoint?.includes("okta")) {
selectedProvider = "okta";
} else {
selectedProvider = "generic";
@ -175,13 +162,10 @@ const SSOModals: React.FC<SSOModalsProps> = ({
...roleMappingFields,
};
console.log("Setting form values:", formValues); // Debug log
// Clear form first, then set values with a small delay to ensure proper initialization
form.resetFields();
setTimeout(() => {
form.setFieldsValue(formValues);
console.log("Form values set, current form values:", form.getFieldsValue()); // Debug log
}, 100);
}
} catch (error) {
@ -218,6 +202,7 @@ const SSOModals: React.FC<SSOModalsProps> = ({
// Add role mappings if use_role_mappings is checked
if (use_role_mappings) {
const provider = rest.sso_provider || "generic";
// Helper function to split comma-separated string into array
const splitTeams = (teams: string | undefined): string[] => {
if (!teams || teams.trim() === "") return [];
@ -236,7 +221,7 @@ const SSOModals: React.FC<SSOModalsProps> = ({
};
payload.role_mappings = {
provider: "generic",
provider,
group_claim,
default_role: defaultRoleMapping[default_role] || "internal_user",
roles: {
@ -273,6 +258,12 @@ const SSOModals: React.FC<SSOModalsProps> = ({
microsoft_client_id: null,
microsoft_client_secret: null,
microsoft_tenant: null,
okta_client_id: null,
okta_client_secret: null,
okta_issuer: null,
okta_authorization_endpoint: null,
okta_token_endpoint: null,
okta_userinfo_endpoint: null,
generic_client_id: null,
generic_client_secret: null,
generic_authorization_endpoint: null,
@ -354,10 +345,7 @@ const SSOModals: React.FC<SSOModalsProps> = ({
/>
)}
<span>
{value.toLowerCase() === "okta"
? "Okta / Auth0"
: value.charAt(0).toUpperCase() + value.slice(1)}{" "}
SSO
{value.charAt(0).toUpperCase() + value.slice(1)} SSO
</span>
</div>
</Select.Option>

View file

@ -285,7 +285,7 @@ describe("renderProviderFields", () => {
it("should return fields for okta provider", () => {
const result = renderProviderFields("okta");
expect(result).not.toBeNull();
expect(result?.length).toBe(5);
expect(result?.length).toBe(3);
});
it("should return fields for generic provider", () => {

View file

@ -46,25 +46,17 @@ export const ssoProviderConfigs: Record<string, SSOProviderConfig> = {
},
okta: {
envVarMap: {
generic_client_id: "GENERIC_CLIENT_ID",
generic_client_secret: "GENERIC_CLIENT_SECRET",
generic_authorization_endpoint: "GENERIC_AUTHORIZATION_ENDPOINT",
generic_token_endpoint: "GENERIC_TOKEN_ENDPOINT",
generic_userinfo_endpoint: "GENERIC_USERINFO_ENDPOINT",
okta_client_id: "OKTA_CLIENT_ID",
okta_client_secret: "OKTA_CLIENT_SECRET",
okta_issuer: "OKTA_ISSUER",
},
fields: [
{ label: "Generic Client ID", name: "generic_client_id" },
{ label: "Generic Client Secret", name: "generic_client_secret" },
{ label: "Okta Client ID", name: "okta_client_id" },
{ label: "Okta Client Secret", name: "okta_client_secret" },
{
label: "Authorization Endpoint",
name: "generic_authorization_endpoint",
placeholder: "https://your-domain/authorize",
},
{ label: "Token Endpoint", name: "generic_token_endpoint", placeholder: "https://your-domain/token" },
{
label: "Userinfo Endpoint",
name: "generic_userinfo_endpoint",
placeholder: "https://your-domain/userinfo",
label: "Okta Issuer",
name: "okta_issuer",
placeholder: "https://your-domain.okta.com/oauth2/default",
},
],
},

View file

@ -24,6 +24,12 @@ const DeleteSSOSettingsModal: React.FC<DeleteSSOSettingsModalProps> = ({ isVisib
microsoft_client_id: null,
microsoft_client_secret: null,
microsoft_tenant: null,
okta_client_id: null,
okta_client_secret: null,
okta_issuer: null,
okta_authorization_endpoint: null,
okta_token_endpoint: null,
okta_userinfo_endpoint: null,
generic_client_id: null,
generic_client_secret: null,
generic_authorization_endpoint: null,

View file

@ -90,6 +90,14 @@ const createGenericSSOData = (overrides: Record<string, any> = {}) =>
...overrides,
});
const createOktaSSOData = (overrides: Record<string, any> = {}) =>
createSSOData({
okta_client_id: "test-okta-id",
okta_client_secret: "test-okta-secret",
okta_issuer: "https://okta.example.com/oauth2/default",
...overrides,
});
const createRoleMappingsSSOData = (overrides: Record<string, any> = {}) =>
createGoogleSSOData({
role_mappings: {
@ -182,6 +190,17 @@ vi.mock("@/components/shared/errorUtils", () => ({
}));
vi.mock("../utils", () => ({
detectSSOProvider: vi.fn((values: Record<string, any>) => {
if (values.google_client_id) return "google";
if (values.microsoft_client_id) return "microsoft";
if (values.okta_client_id) return "okta";
if (values.generic_client_id) {
return values.generic_authorization_endpoint?.includes("okta")
? "okta"
: "generic";
}
return null;
}),
processSSOSettingsPayload: vi.fn(),
}));
@ -398,20 +417,14 @@ describe("EditSSOSettingsModal", () => {
testProviderDetection("Microsoft", createMicrosoftSSOData(), SSO_PROVIDERS.MICROSOFT);
testProviderDetection(
"Okta",
createGenericSSOData({
authorization_endpoint: "https://okta.example.com/oauth2/authorize",
}),
SSO_PROVIDERS.OKTA,
);
testProviderDetection("Okta", createOktaSSOData(), SSO_PROVIDERS.OKTA);
testProviderDetection(
"Auth0 (detected as Okta)",
"Auth0 generic endpoint",
createGenericSSOData({
authorization_endpoint: "https://auth0.example.com/authorize",
}),
SSO_PROVIDERS.OKTA, // Auth0 URLs are detected as Okta provider
SSO_PROVIDERS.GENERIC,
);
testProviderDetection("generic", createGenericSSOData(), SSO_PROVIDERS.GENERIC);

View file

@ -5,7 +5,7 @@ import React, { useEffect } from "react";
import BaseSSOSettingsForm from "./BaseSSOSettingsForm";
import NotificationsManager from "@/components/molecules/notifications_manager";
import { parseErrorMessage } from "@/components/shared/errorUtils";
import { processSSOSettingsPayload } from "../utils";
import { detectSSOProvider, processSSOSettingsPayload } from "../utils";
import { useSSOSettings } from "@/app/(dashboard)/hooks/sso/useSSOSettings";
import { useEditSSOSettings } from "@/app/(dashboard)/hooks/sso/useEditSSOSettings";
@ -24,27 +24,9 @@ const EditSSOSettingsModal: React.FC<EditSSOSettingsModalProps> = ({ isVisible,
useEffect(() => {
if (isVisible && ssoSettings.data && ssoSettings.data.values) {
const ssoData = ssoSettings.data;
console.log("Raw SSO data received:", ssoData); // Debug log
console.log("SSO values:", ssoData.values); // Debug log
console.log("user_email from API:", ssoData.values.user_email); // Debug log
// Determine which SSO provider is configured
let selectedProvider = null;
if (ssoData.values.google_client_id) {
selectedProvider = "google";
} else if (ssoData.values.microsoft_client_id) {
selectedProvider = "microsoft";
} else if (ssoData.values.generic_client_id) {
// Check if it looks like Okta based on endpoints
if (
ssoData.values.generic_authorization_endpoint?.includes("okta") ||
ssoData.values.generic_authorization_endpoint?.includes("auth0")
) {
selectedProvider = "okta";
} else {
selectedProvider = "generic";
}
}
const selectedProvider = detectSSOProvider(ssoData.values);
// Extract role mappings if they exist
let roleMappingFields = {};
@ -86,13 +68,10 @@ const EditSSOSettingsModal: React.FC<EditSSOSettingsModalProps> = ({ isVisible,
...teamMappingFields,
};
console.log("Setting form values:", formValues); // Debug log
// Clear form first, then set values with a small delay to ensure proper initialization
form.resetFields();
setTimeout(() => {
form.setFieldsValue(formValues);
console.log("Form values set, current form values:", form.getFieldsValue()); // Debug log
}, 100);
}
}, [isVisible, ssoSettings.data, form]);

View file

@ -24,6 +24,7 @@ export default function SSOSettings() {
const isSSOConfigured =
Boolean(ssoSettings?.values.google_client_id) ||
Boolean(ssoSettings?.values.microsoft_client_id) ||
Boolean(ssoSettings?.values.okta_client_id) ||
Boolean(ssoSettings?.values.generic_client_id);
const selectedProvider = ssoSettings?.values ? detectSSOProvider(ssoSettings.values) : null;
@ -94,23 +95,15 @@ export default function SSOSettings() {
fields: [
{
label: "Client ID",
render: (values: SSOSettingsValues) => <RedactableField value={values.generic_client_id} />,
render: (values: SSOSettingsValues) => <RedactableField value={values.okta_client_id} />,
},
{
label: "Client Secret",
render: (values: SSOSettingsValues) => <RedactableField value={values.generic_client_secret} />,
render: (values: SSOSettingsValues) => <RedactableField value={values.okta_client_secret} />,
},
{
label: "Authorization Endpoint",
render: (values: SSOSettingsValues) => renderEndpointValue(values.generic_authorization_endpoint),
},
{
label: "Token Endpoint",
render: (values: SSOSettingsValues) => renderEndpointValue(values.generic_token_endpoint),
},
{
label: "User Info Endpoint",
render: (values: SSOSettingsValues) => renderEndpointValue(values.generic_userinfo_endpoint),
label: "Issuer",
render: (values: SSOSettingsValues) => renderEndpointValue(values.okta_issuer),
},
{ label: "Proxy Base URL", render: (values: SSOSettingsValues) => renderSimpleValue(values.proxy_base_url) },
isTeamMappingsEnabled ? {

View file

@ -10,7 +10,7 @@ export const ssoProviderLogoMap: Record<string, string> = {
export const ssoProviderDisplayNames: Record<string, string> = {
google: "Google SSO",
microsoft: "Microsoft SSO",
okta: "Okta / Auth0 SSO",
okta: "Okta SSO",
generic: "Generic SSO",
};

View file

@ -392,6 +392,7 @@ describe("processSSOSettingsPayload", () => {
team_ids_jwt_field: "teams",
});
expect(result.role_mappings).toBeDefined();
expect(result.role_mappings.provider).toBe("okta");
expect(result.role_mappings.group_claim).toBe("groups");
});
});

View file

@ -44,7 +44,7 @@ export const processSSOSettingsPayload = (formValues: Record<string, any>): Reco
};
payload.role_mappings = {
provider: "generic",
provider,
group_claim,
default_role: defaultRoleMapping[default_role] || "internal_user",
roles: {
@ -71,12 +71,10 @@ export const processSSOSettingsPayload = (formValues: Record<string, any>): Reco
export const detectSSOProvider = (values: SSOSettingsValues): string | null => {
if (values.google_client_id) return "google";
if (values.microsoft_client_id) return "microsoft";
if (values.okta_client_id) return "okta";
if (values.generic_client_id) {
// Check if it looks like Okta/Auth0 based on endpoints
if (
values.generic_authorization_endpoint?.includes("okta") ||
values.generic_authorization_endpoint?.includes("auth0")
) {
// Backward compatibility: older Okta UI settings were stored as generic OIDC endpoints.
if (values.generic_authorization_endpoint?.includes("okta")) {
return "okta";
}
return "generic";