chainlit icon indicating copy to clipboard operation
chainlit copied to clipboard

no user role with Azure AD authentication

Open weipienlee opened this issue 1 year ago • 19 comments
trafficstars

Although authentication works, with the below I couldn't find any info on user roles that are assigned to users in the App Registration.

def oauth_callback(provider_id: str, token: str, raw_user_data: dict, default_user: cl.User): print(f"{provider_id=}") print(f"{token=}") print(f"{raw_user_data=}") print(f"{default_user=}") return default_user

weipienlee avatar Jul 19 '24 01:07 weipienlee

You need azure-ad-hybrid authentication for that. It was just added a few weeks ago.

hayescode avatar Jul 19 '24 05:07 hayescode

that's great news @hayescode, but how/where? I'm on the latest 1.1.400r1

weipienlee avatar Jul 19 '24 10:07 weipienlee

ah, the env vars. Got it!

weipienlee avatar Jul 19 '24 10:07 weipienlee

having troubles after replacing the OAUTH_AZURE_AD_* keys with OAUTH_AZURE_AD_HYBRID_*. Get double login buttons, one works, the other seems to have no call_back uri. But that button shouldn't be there in the first place, i guess.

image

AADSTS700016: Application with identifier 'None' was not found in the directory 'Signify'. This can happen if the application has not been installed by the administrator of the tenant or consented to by any user in the tenant. You may have sent your authentication request to the wrong tenant.

weipienlee avatar Jul 19 '24 10:07 weipienlee

@hayescode I looked at the raw user data (provider_id='azure-ad-hybrid'), but can't find the roles. These are the keys (no nested values):

raw_user_data.keys()=dict_keys(['@odata.context', 'businessPhones', 'displayName', 'givenName', 'jobTitle', 'mail', 'mobilePhone', 'officeLocation', 'preferredLanguage', 'surname', 'userPrincipalName', 'id', 'image'])

weipienlee avatar Jul 19 '24 10:07 weipienlee

Add id_token to your params in the decorated login params, then decode it using jwt. ChatGPT will show you how to decode/extract the app role.

hayescode avatar Jul 19 '24 14:07 hayescode

Not very familiar with this. When not using msal, it gets tricky very quickly. This what I used:

@cl.oauth_callback
def oauth_callback(provider_id: str, token: str, raw_user_data: dict, default_user: cl.User):
    from msal.oauth2cli.oidc import decode_id_token 
    a = decode_id_token(id_token=token, client_id=os.environ['OAUTH_AZURE_AD_HYBRID_CLIENT_ID'])
    print(a)

this is what it returns:

msal.oauth2cli.oidc.IdTokenAudienceError: 3. The aud (audience) claim must contain this client's client_id "---GUID---", case-sensitively. Was your client_id in wrong casing? Current epoch = 2024-07-19 18:51:19. The id_token was approximately: { "aud": "https://graph.microsoft.com", ...

it's https://graph.microsoft.com instead. I can fill that in and it will not complain, but that doesn't make any sense. Furthermore, no roles.

weipienlee avatar Jul 19 '24 17:07 weipienlee

Add id_token params at the end.

@cl.oauth_callback
def oauth_callback(provider_id: str, token: str, raw_user_data: dict, default_user: cl.User, id_token: str):

Then decode the id_token. You don't need any other API calls you already have the token with app roles here.

hayescode avatar Jul 21 '24 11:07 hayescode

Yes, that does it @hayescode! Last issue, do you know why there are 2 auth "flows"/buttons presented? The one not working calls a microsoft authorize with:

  • client_id: None
  • tenant: None
  • redirect_uri=http://localhost:8000/auth/oauth/azure-ad/callback

It seems it is the "azure-ad" flow, for which I didn't provide the env vars of course. I guess I need a way to disable it somehow?

image

weipienlee avatar Jul 21 '24 15:07 weipienlee

For those with the same problem, I have a temporary work around. Add this to stylesheet.css:

/* Temporary patch for "duplicated" continue with Microsoft */
.MuiStack-root .MuiButtonBase-root:first-of-type:has(span > svg[data-testid="MicrosoftIcon"]) {
    display: none;
}

weipienlee avatar Jul 22 '24 22:07 weipienlee

could u guys tell how did u authorize access to the users based on their roles? it seems like im having a problem after decoding the id_tokens. the id_token wont store anywhere.

wac4s avatar Jan 14 '25 11:01 wac4s

It has been a while, but I did it this way quick and dirty. i'm not an expert. There are other ways to decode the token and at this stage i don't if you need to verify the the token first for security, @hayescode do you know?

from msal.oauth2cli.oidc import decode_id_token
@cl.oauth_callback # type: ignore new api not official yet
def oauth_callback(
    provider_id: str,
    token: str,
    raw_user_data: dict,
    default_user: cl.User,
    id_token: str, # new parameter
):
    dec_id_token = decode_id_token(
        id_token=id_token, client_id=os.environ["OAUTH_AZURE_AD_HYBRID_CLIENT_ID"]
    )
    default_user.metadata["roles"] = dec_id_token["roles"]
    return default_user

Now you access the user data via the chainlit api like e.g.:

@cl.set_chat_profiles 
def set_chat_profiles(user: Optional[cl.User]):

    if "SomeRole" in user.metadata["roles"]:
        apps = all_apps
    else:
        apps = public_apps
    return [cast(cl.ChatProfile, app.profile()) for app in apps]

Also, i'm on an old version ^1.1.402, so things might have changed since.

weipienlee avatar Jan 14 '25 14:01 weipienlee

@weipienlee yes this is basically what I do too

hayescode avatar Jan 14 '25 18:01 hayescode

thankyou so much, this metadata """default_user.metadata["roles"]""" was missing in my code. i had no idea what to do with the id_token coz it wont pass in the chat profile function.

wac4s avatar Jan 14 '25 18:01 wac4s

@weipienlee yes this is basically what I do too

@hayescode, is token verification still required or has that been done by the chainlit framework? meaning, after the user logs in via an auth provider let's say microsoft AAD, is the callback first handled by the framework and so performing a token verification with the returned auth code and such, before it "hands it over" to "@cl.oauth_callback"?

weipienlee avatar Jan 14 '25 18:01 weipienlee

thankyou so much, this metadata """default_user.metadata["roles"]""" was missing in my code. i had no idea what to do with the id_token coz it wont pass in the chat profile function.

hope that solves your problem, remember, metadata["roles"] is just dict key-value defined by you.

weipienlee avatar Jan 14 '25 18:01 weipienlee

Thanks for your directions, here is a complete solution that verifies the signature too

import os
import time

import jwt
import requests
from jwt.algorithms import RSAAlgorithm

CLIENT_ID = os.environ["OAUTH_AZURE_AD_HYBRID_CLIENT_ID"]
TENANT_ID = os.environ.get("OAUTH_AZURE_AD_HYBRID_TENANT_ID")
JWKS_URL = f"https://login.microsoftonline.com/{TENANT_ID}/discovery/v2.0/keys"
JWKS_CACHE = {"keys": None, "last_fetch": 0, "ttl": 3600}


def fetch_jwks():
    global JWKS_CACHE
    current_time = time.time()

    if JWKS_CACHE["keys"] and (current_time - JWKS_CACHE["last_fetch"]) < JWKS_CACHE["ttl"]:
        return JWKS_CACHE["keys"]

    response = requests.get(JWKS_URL)
    JWKS_CACHE["keys"] = response.json()
    JWKS_CACHE["last_fetch"] = current_time

    return JWKS_CACHE["keys"]


def validate_and_get_azure_id_token(id_token):
    jwks = fetch_jwks()
    header = jwt.get_unverified_header(id_token)
    kid = header["kid"]
    key = next((key for key in jwks["keys"] if key["kid"] == kid), None)
    if not key:
        raise ValueError("❌ Key still not found after refresh.")

    try:
        decoded_token = jwt.decode(
            id_token,
            RSAAlgorithm.from_jwk(key),
            algorithms=["RS256"],
            audience=f"{CLIENT_ID}",
            issuer=f"https://login.microsoftonline.com/{TENANT_ID}/v2.0"
        )
        return decoded_token
    except jwt.ExpiredSignatureError:
        return None
    except jwt.InvalidTokenError:
        return None

@cl.oauth_callback
def oauth_callback(
        provider_id: str,
        token: str,
        raw_user_data: Dict[str, str],
        default_user: cl.User,
        id_token: str = None,
) -> Optional[cl.User]:
    if provider_id == "azure-ad-hybrid":
        default_user.display_name = raw_user_data.get("displayName", default_user.identifier)
        decoded_id_token = validate_and_get_azure_id_token(id_token)
        default_user.metadata["roles"] = decoded_id_token["groups"]  # todo: map guid's to roles
        return default_user
    return None

wizhippo avatar Mar 07 '25 22:03 wizhippo

tnx @wizhippo, I also tried packages like msal or authlib which might also be interesting.

weipienlee avatar Mar 13 '25 14:03 weipienlee

Created PR 2033

Has easy implementation for the user's group integration with metadata.

parthbs avatar Mar 21 '25 07:03 parthbs

This issue is stale because it has been open for 14 days with no activity.

github-actions[bot] avatar Jul 23 '25 02:07 github-actions[bot]

This issue was closed because it has been inactive for 7 days since being marked as stale.

github-actions[bot] avatar Jul 30 '25 02:07 github-actions[bot]