mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-23 00:41:40 +00:00
Add first-class Okta OIDC auth support
This commit is contained in:
parent
8eecf76d36
commit
5a486d3336
24 changed files with 647 additions and 222 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
85
litellm/proxy/example_config_yaml/okta_jwt_auth_config.yaml
Normal file
85
litellm/proxy/example_config_yaml/okta_jwt_auth_config.yaml
Normal 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
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)",
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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"""
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
},
|
||||
],
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
|
|
|
|||
|
|
@ -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 ? {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue