58
58
has_admin_ui_access ,
59
59
)
60
60
from litellm .proxy .management_endpoints .team_endpoints import new_team , team_member_add
61
- from litellm .proxy .management_endpoints .types import CustomOpenID
61
+ from litellm .proxy .management_endpoints .types import CustomOpenID , get_litellm_user_role
62
62
from litellm .proxy .utils import (
63
63
PrismaClient ,
64
64
ProxyLogging ,
@@ -277,6 +277,7 @@ def generic_response_convertor(
277
277
last_name = response .get (generic_user_last_name_attribute_name ),
278
278
provider = response .get (generic_provider_attribute_name ),
279
279
team_ids = all_teams ,
280
+ user_role = None ,
280
281
)
281
282
282
283
@@ -1145,7 +1146,7 @@ def get_redirect_url_for_sso(
1145
1146
) -> str :
1146
1147
"""
1147
1148
Get the redirect URL for SSO
1148
-
1149
+
1149
1150
Note: existing_key is not added to the URL to avoid changing the callback URL.
1150
1151
It should be passed via the state parameter instead.
1151
1152
"""
@@ -1348,7 +1349,7 @@ def _get_cli_state(
1348
1349
Checks the request 'source' if a cli state token was passed in
1349
1350
1350
1351
This is used to authenticate through the CLI login flow.
1351
-
1352
+
1352
1353
The state parameter format is: {PREFIX}:{key}:{existing_key}
1353
1354
- If existing_key is provided, it's included in the state
1354
1355
- The state parameter is used to pass data through the OAuth flow without changing the callback URL
@@ -1673,22 +1674,49 @@ async def get_microsoft_callback_response(
1673
1674
access_token = microsoft_sso .access_token
1674
1675
)
1675
1676
1677
+ # Extract app roles from the id_token JWT
1678
+ app_roles = MicrosoftSSOHandler .get_app_roles_from_id_token (
1679
+ id_token = microsoft_sso .id_token
1680
+ )
1681
+ verbose_proxy_logger .debug (f"Extracted app roles from id_token: { app_roles } " )
1682
+
1683
+ # Combine groups and app roles
1684
+ user_role : Optional [LitellmUserRoles ] = None
1685
+ if app_roles :
1686
+ # Check if any app role is a valid LitellmUserRoles
1687
+ for role_str in app_roles :
1688
+ role = get_litellm_user_role (role_str )
1689
+ if role is not None :
1690
+ user_role = role
1691
+ verbose_proxy_logger .debug (
1692
+ f"Found valid LitellmUserRoles '{ role .value } ' in app_roles"
1693
+ )
1694
+ break
1695
+
1696
+ verbose_proxy_logger .debug (
1697
+ f"Combined team_ids (groups + app roles): { user_team_ids } "
1698
+ )
1699
+
1676
1700
# if user is trying to get the raw sso response for debugging, return the raw sso response
1677
1701
if return_raw_sso_response :
1678
1702
original_msft_result [MicrosoftSSOHandler .GRAPH_API_RESPONSE_KEY ] = (
1679
1703
user_team_ids
1680
1704
)
1705
+ original_msft_result ["app_roles" ] = app_roles
1681
1706
return original_msft_result or {}
1682
1707
1683
1708
result = MicrosoftSSOHandler .openid_from_response (
1684
1709
response = original_msft_result ,
1685
1710
team_ids = user_team_ids ,
1711
+ user_role = user_role ,
1686
1712
)
1687
1713
return result
1688
1714
1689
1715
@staticmethod
1690
1716
def openid_from_response (
1691
- response : Optional [dict ], team_ids : List [str ]
1717
+ response : Optional [dict ],
1718
+ team_ids : List [str ],
1719
+ user_role : Optional [LitellmUserRoles ],
1692
1720
) -> CustomOpenID :
1693
1721
response = response or {}
1694
1722
verbose_proxy_logger .debug (f"Microsoft SSO Callback Response: { response } " )
@@ -1700,10 +1728,54 @@ def openid_from_response(
1700
1728
first_name = response .get ("givenName" ),
1701
1729
last_name = response .get ("surname" ),
1702
1730
team_ids = team_ids ,
1731
+ user_role = user_role ,
1703
1732
)
1704
1733
verbose_proxy_logger .debug (f"Microsoft SSO OpenID Response: { openid_response } " )
1705
1734
return openid_response
1706
1735
1736
+ @staticmethod
1737
+ def get_app_roles_from_id_token (id_token : Optional [str ]) -> List [str ]:
1738
+ """
1739
+ Extract app roles from the Microsoft Entra ID (Azure AD) id_token JWT.
1740
+
1741
+ App roles are assigned in the Azure AD Enterprise Application and appear
1742
+ in the 'roles' claim of the id_token.
1743
+
1744
+ Args:
1745
+ id_token (Optional[str]): The JWT id_token from Microsoft SSO
1746
+
1747
+ Returns:
1748
+ List[str]: List of app role names assigned to the user
1749
+ """
1750
+ if not id_token :
1751
+ verbose_proxy_logger .debug ("No id_token provided for app role extraction" )
1752
+ return []
1753
+
1754
+ try :
1755
+ import jwt
1756
+
1757
+ # Decode the JWT without signature verification
1758
+ # (signature is already verified by fastapi_sso)
1759
+ decoded_token = jwt .decode (id_token , options = {"verify_signature" : False })
1760
+
1761
+ # Extract roles claim from the token
1762
+ roles = decoded_token .get ("roles" , [])
1763
+
1764
+ if roles and isinstance (roles , list ):
1765
+ verbose_proxy_logger .debug (
1766
+ f"Found { len (roles )} app role(s) in id_token: { roles } "
1767
+ )
1768
+ return roles
1769
+ else :
1770
+ verbose_proxy_logger .debug (
1771
+ "No app roles found in id_token or roles claim is not a list"
1772
+ )
1773
+ return []
1774
+
1775
+ except Exception as e :
1776
+ verbose_proxy_logger .error (f"Error extracting app roles from id_token: { e } " )
1777
+ return []
1778
+
1707
1779
@staticmethod
1708
1780
async def get_user_groups_from_graph_api (
1709
1781
access_token : Optional [str ] = None ,
0 commit comments