diff --git a/litellm/proxy/_types.py b/litellm/proxy/_types.py index 6128f485748..beb12abf6b0 100644 --- a/litellm/proxy/_types.py +++ b/litellm/proxy/_types.py @@ -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 diff --git a/litellm/proxy/auth/auth_utils.py b/litellm/proxy/auth/auth_utils.py index 567b8307af2..a6ba2e02231 100644 --- a/litellm/proxy/auth/auth_utils.py +++ b/litellm/proxy/auth/auth_utils.py @@ -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) ) diff --git a/litellm/proxy/auth/handle_jwt.py b/litellm/proxy/auth/handle_jwt.py index 5b116c142b2..2a5b513125c 100644 --- a/litellm/proxy/auth/handle_jwt.py +++ b/litellm/proxy/auth/handle_jwt.py @@ -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) diff --git a/litellm/proxy/example_config_yaml/okta_jwt_auth_config.yaml b/litellm/proxy/example_config_yaml/okta_jwt_auth_config.yaml new file mode 100644 index 00000000000..39da65c804b --- /dev/null +++ b/litellm/proxy/example_config_yaml/okta_jwt_auth_config.yaml @@ -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:///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 +# 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 → → Claims → Add Claim + # Name: groups, Include in: ID Token, Value type: Groups, Filter: Starts with + 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 diff --git a/litellm/proxy/management_endpoints/ui_sso.py b/litellm/proxy/management_endpoints/ui_sso.py index 6e2e2bedac1..1298b098aae 100644 --- a/litellm/proxy/management_endpoints/ui_sso.py +++ b/litellm/proxy/management_endpoints/ui_sso.py @@ -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( diff --git a/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py b/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py index db3ae9ad942..0f88144a2d0 100644 --- a/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py +++ b/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py @@ -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) diff --git a/litellm/types/proxy/management_endpoints/ui_sso.py b/litellm/types/proxy/management_endpoints/ui_sso.py index 7d8ff0f65c1..dc6e33cf920 100644 --- a/litellm/types/proxy/management_endpoints/ui_sso.py +++ b/litellm/types/proxy/management_endpoints/ui_sso.py @@ -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)", diff --git a/tests/test_litellm/proxy/auth/test_handle_jwt.py b/tests/test_litellm/proxy/auth/test_handle_jwt.py index 90c5d4f4fc5..347e1dbf58d 100644 --- a/tests/test_litellm/proxy/auth/test_handle_jwt.py +++ b/tests/test_litellm/proxy/auth/test_handle_jwt.py @@ -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) # --------------------------------------------------------------------------- diff --git a/tests/test_litellm/proxy/management_endpoints/test_ui_sso.py b/tests/test_litellm/proxy/management_endpoints/test_ui_sso.py index 83317157847..47f1a94d5f9 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_ui_sso.py +++ b/tests/test_litellm/proxy/management_endpoints/test_ui_sso.py @@ -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""" diff --git a/tests/test_litellm/proxy/ui_crud_endpoints/test_proxy_setting_endpoints.py b/tests/test_litellm/proxy/ui_crud_endpoints/test_proxy_setting_endpoints.py index c27d7eedcdb..aa641c05384 100644 --- a/tests/test_litellm/proxy/ui_crud_endpoints/test_proxy_setting_endpoints.py +++ b/tests/test_litellm/proxy/ui_crud_endpoints/test_proxy_setting_endpoints.py @@ -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" diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/sso/useEditSSOSettings.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/sso/useEditSSOSettings.ts index 69e52d0ff25..addf16756c7 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/hooks/sso/useEditSSOSettings.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/sso/useEditSSOSettings.ts @@ -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; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/sso/useSSOSettings.test.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/sso/useSSOSettings.test.ts index 4e8d892b5d8..55e4f43c334 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/hooks/sso/useSSOSettings.test.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/sso/useSSOSettings.test.ts @@ -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, diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/sso/useSSOSettings.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/sso/useSSOSettings.ts index 0431a8d39f7..20d18d6e644 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/hooks/sso/useSSOSettings.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/sso/useSSOSettings.ts @@ -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; diff --git a/ui/litellm-dashboard/src/components/SSOModals.test.tsx b/ui/litellm-dashboard/src/components/SSOModals.test.tsx index 365d23f4036..a2461b96e6f 100644 --- a/ui/litellm-dashboard/src/components/SSOModals.test.tsx +++ b/ui/litellm-dashboard/src/components/SSOModals.test.tsx @@ -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, diff --git a/ui/litellm-dashboard/src/components/SSOModals.tsx b/ui/litellm-dashboard/src/components/SSOModals.tsx index e063b0ed431..9f4cce28839 100644 --- a/ui/litellm-dashboard/src/components/SSOModals.tsx +++ b/ui/litellm-dashboard/src/components/SSOModals.tsx @@ -61,25 +61,17 @@ const ssoProviderConfigs: Record = { }, 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 = ({ 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 = ({ ...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 = ({ // 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 = ({ }; payload.role_mappings = { - provider: "generic", + provider, group_claim, default_role: defaultRoleMapping[default_role] || "internal_user", roles: { @@ -273,6 +258,12 @@ const SSOModals: React.FC = ({ 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 = ({ /> )} - {value.toLowerCase() === "okta" - ? "Okta / Auth0" - : value.charAt(0).toUpperCase() + value.slice(1)}{" "} - SSO + {value.charAt(0).toUpperCase() + value.slice(1)} SSO diff --git a/ui/litellm-dashboard/src/components/Settings/AdminSettings/SSOSettings/Modals/BaseSSOSettingsForm.test.tsx b/ui/litellm-dashboard/src/components/Settings/AdminSettings/SSOSettings/Modals/BaseSSOSettingsForm.test.tsx index c68e2716f5b..40f23fe552e 100644 --- a/ui/litellm-dashboard/src/components/Settings/AdminSettings/SSOSettings/Modals/BaseSSOSettingsForm.test.tsx +++ b/ui/litellm-dashboard/src/components/Settings/AdminSettings/SSOSettings/Modals/BaseSSOSettingsForm.test.tsx @@ -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", () => { diff --git a/ui/litellm-dashboard/src/components/Settings/AdminSettings/SSOSettings/Modals/BaseSSOSettingsForm.tsx b/ui/litellm-dashboard/src/components/Settings/AdminSettings/SSOSettings/Modals/BaseSSOSettingsForm.tsx index d16b04466e0..e713257929e 100644 --- a/ui/litellm-dashboard/src/components/Settings/AdminSettings/SSOSettings/Modals/BaseSSOSettingsForm.tsx +++ b/ui/litellm-dashboard/src/components/Settings/AdminSettings/SSOSettings/Modals/BaseSSOSettingsForm.tsx @@ -46,25 +46,17 @@ export const ssoProviderConfigs: Record = { }, 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", }, ], }, diff --git a/ui/litellm-dashboard/src/components/Settings/AdminSettings/SSOSettings/Modals/DeleteSSOSettingsModal.tsx b/ui/litellm-dashboard/src/components/Settings/AdminSettings/SSOSettings/Modals/DeleteSSOSettingsModal.tsx index 2656c861aa8..2e2dc44e554 100644 --- a/ui/litellm-dashboard/src/components/Settings/AdminSettings/SSOSettings/Modals/DeleteSSOSettingsModal.tsx +++ b/ui/litellm-dashboard/src/components/Settings/AdminSettings/SSOSettings/Modals/DeleteSSOSettingsModal.tsx @@ -24,6 +24,12 @@ const DeleteSSOSettingsModal: React.FC = ({ 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, diff --git a/ui/litellm-dashboard/src/components/Settings/AdminSettings/SSOSettings/Modals/EditSSOSettingsModal.test.tsx b/ui/litellm-dashboard/src/components/Settings/AdminSettings/SSOSettings/Modals/EditSSOSettingsModal.test.tsx index d2d54033395..07b51a39db2 100644 --- a/ui/litellm-dashboard/src/components/Settings/AdminSettings/SSOSettings/Modals/EditSSOSettingsModal.test.tsx +++ b/ui/litellm-dashboard/src/components/Settings/AdminSettings/SSOSettings/Modals/EditSSOSettingsModal.test.tsx @@ -90,6 +90,14 @@ const createGenericSSOData = (overrides: Record = {}) => ...overrides, }); +const createOktaSSOData = (overrides: Record = {}) => + 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 = {}) => createGoogleSSOData({ role_mappings: { @@ -182,6 +190,17 @@ vi.mock("@/components/shared/errorUtils", () => ({ })); vi.mock("../utils", () => ({ + detectSSOProvider: vi.fn((values: Record) => { + 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); diff --git a/ui/litellm-dashboard/src/components/Settings/AdminSettings/SSOSettings/Modals/EditSSOSettingsModal.tsx b/ui/litellm-dashboard/src/components/Settings/AdminSettings/SSOSettings/Modals/EditSSOSettingsModal.tsx index bbae8f1451a..c00d039e614 100644 --- a/ui/litellm-dashboard/src/components/Settings/AdminSettings/SSOSettings/Modals/EditSSOSettingsModal.tsx +++ b/ui/litellm-dashboard/src/components/Settings/AdminSettings/SSOSettings/Modals/EditSSOSettingsModal.tsx @@ -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 = ({ 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 = ({ 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]); diff --git a/ui/litellm-dashboard/src/components/Settings/AdminSettings/SSOSettings/SSOSettings.tsx b/ui/litellm-dashboard/src/components/Settings/AdminSettings/SSOSettings/SSOSettings.tsx index fdeda0ece3e..5f374bb0ce7 100644 --- a/ui/litellm-dashboard/src/components/Settings/AdminSettings/SSOSettings/SSOSettings.tsx +++ b/ui/litellm-dashboard/src/components/Settings/AdminSettings/SSOSettings/SSOSettings.tsx @@ -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) => , + render: (values: SSOSettingsValues) => , }, { label: "Client Secret", - render: (values: SSOSettingsValues) => , + render: (values: SSOSettingsValues) => , }, { - 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 ? { diff --git a/ui/litellm-dashboard/src/components/Settings/AdminSettings/SSOSettings/constants.ts b/ui/litellm-dashboard/src/components/Settings/AdminSettings/SSOSettings/constants.ts index e2aa21e4b25..2d51d37335a 100644 --- a/ui/litellm-dashboard/src/components/Settings/AdminSettings/SSOSettings/constants.ts +++ b/ui/litellm-dashboard/src/components/Settings/AdminSettings/SSOSettings/constants.ts @@ -10,7 +10,7 @@ export const ssoProviderLogoMap: Record = { export const ssoProviderDisplayNames: Record = { google: "Google SSO", microsoft: "Microsoft SSO", - okta: "Okta / Auth0 SSO", + okta: "Okta SSO", generic: "Generic SSO", }; diff --git a/ui/litellm-dashboard/src/components/Settings/AdminSettings/SSOSettings/utils.test.ts b/ui/litellm-dashboard/src/components/Settings/AdminSettings/SSOSettings/utils.test.ts index 722d52d64f9..3a1350a7076 100644 --- a/ui/litellm-dashboard/src/components/Settings/AdminSettings/SSOSettings/utils.test.ts +++ b/ui/litellm-dashboard/src/components/Settings/AdminSettings/SSOSettings/utils.test.ts @@ -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"); }); }); diff --git a/ui/litellm-dashboard/src/components/Settings/AdminSettings/SSOSettings/utils.ts b/ui/litellm-dashboard/src/components/Settings/AdminSettings/SSOSettings/utils.ts index 948ed4d2bfe..d70d7f950fd 100644 --- a/ui/litellm-dashboard/src/components/Settings/AdminSettings/SSOSettings/utils.ts +++ b/ui/litellm-dashboard/src/components/Settings/AdminSettings/SSOSettings/utils.ts @@ -44,7 +44,7 @@ export const processSSOSettingsPayload = (formValues: Record): 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): 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";