pythonhttpazure-active-directoryfastapiurl-fragment

How to get parameters after hash mark # from a redirect URL in FastAPI?


I am facing an issue while working with Redirect Url and getting the data from QueryParams in Python using FastApi. I am using Azure AD Authorization Grant Flow to log in, below is the code which generates the RedirectResponse

@app.get("/auth/oauth/{provider_id}")
async def oauth_login(provider_id: str, request: Request):
    if config.code.oauth_callback is None:
        raise HTTPException(
            status_code=status.HTTP_400_BAD_REQUEST,
            detail="No oauth_callback defined",
        )

    provider = get_oauth_provider(provider_id)
    if not provider:
        raise HTTPException(
            status_code=status.HTTP_404_NOT_FOUND,
            detail=f"Provider {provider_id} not found",
        )

    random = random_secret(32)

    params = urllib.parse.urlencode(
        {
            "client_id": provider.client_id,
            "redirect_uri": f"{get_user_facing_url(request.url)}/callback",
            "state": random,
            **provider.authorize_params,
        }
    )
    response = RedirectResponse(
        url=f"{provider.authorize_url}?{params}")
    samesite = os.environ.get("CHAINLIT_COOKIE_SAMESITE", "lax")  # type: Any
    secure = samesite.lower() == "none"
    response.set_cookie(
        "oauth_state",
        random,
        httponly=True,
        samesite=samesite,
        secure=secure,
        max_age=3 * 60,
    )
    return response

And this is where I am receiving the Redirect URL.

@app.get("/auth/oauth/{provider_id}/callback")
async def oauth_callback(
    provider_id: str,
    request: Request,
    error: Optional[str] = None,
    code: Optional[str] = None,
    state: Optional[str] = None,
):
    if config.code.oauth_callback is None:
        raise HTTPException(
            status_code=status.HTTP_400_BAD_REQUEST,
            detail="No oauth_callback defined",
        )
    provider = get_oauth_provider(provider_id)
    if not provider:
        raise HTTPException(
            status_code=status.HTTP_404_NOT_FOUND,
            detail=f"Provider {provider_id} not found",
        )


    
    if not code or not state:
        raise HTTPException(
            status_code=status.HTTP_400_BAD_REQUEST,
            detail="Missing code or state",
        )
    response.delete_cookie("oauth_state")
    return response

This redirect works fine when the QueryParams are with ? but the issue right now is that the redirect callback from Azure AD is with # and due to that I am not able to get the Code & State QueryParams from the Url

Example of RedirectUrl with #

http://localhost/callback#code=xxxxxx&state=yyyyyy

Any thoughts on how to fix this issue.


Solution

  • Getting get the text (or key-value pairs, in your case) after the hash mark # on server side— the # in a URL is also known as the URI fragment (see Text fragments on MDN documentation as well)—is currently not possible. This is simply because the fragment is never sent to the server (related posts can be found here and here).

    I would suggest using the question mark in the URL ?, which is the proper way to send query parameters in an HTTP request. If this was a complex path parameter, you could instead follow the approach described in this answer, which would allow you to capture the whole URL path, including characters such as / and %, but still not text/values after #.

    Update

    Since the "fragment" is only available/accessible on client side, you could use JavaScript to obtain the hash property of the Location interface, i.e., window.location.hash. To do that, you could have a /callback_init endpoint that is initially called and used as the redirect_uri for the authorization server, and which will return the relevant JavaScript code to read the fragment and pass it in the query string of the URL to the final /callback endpoint. This can easily be done by replaing the # with ? in the URL, as follows:

    @app.get('/callback_init', response_class=HTMLResponse)
    async def callback_init(request: Request):
        html_content = """
        <html>
           <head>
              <script>
                 var url = window.location.href;
                 newUrl = url.replace('/callback_init', '/callback').replace("#", "?");
                 window.location.replace(newUrl);
              </script>
           </head>
        </html>
        """
        return HTMLResponse(content=html_content, status_code=200)
    

    However, the approach above would not respect the possibility of query parameters already present in the URL (even though this is not an issue in your case); hence, one could instead use the below.

    The example below also takes into account that you had set a cookie, which you had to remove afterwards; hence, this is demonstrated below as well. Also, note that for replacing the URL in the browser's address bar (in other words, sending a request to the /callback endpoint), window.location.replace() is used, which, as explained in this answer, won't let the current page (before navigating to the next one) to be saved in session history, meaning that the user won't be able to use the back button in the browser to navigate back to it (if, for some reason, you had to allow the user going back, you could use window.location.href or window.location.assign() instead).

    If you would like hiding the path and/or query parameters from the URL, you can use an approach similar to this answer and this answer. However, this wouldn't mean that the URL, including such path/query parameters would not make it to the browsing history, etc., already. Hence, you should be aware that sending sensitive information in the query string is not safe—please refer to this answer for more details on that subject.

    Working Example

    To trigger the redirection, please go to your browser and call http://localhost:8000/.

    from fastapi import FastAPI, Request, Response
    from fastapi.responses import RedirectResponse, HTMLResponse
    from typing import Optional
    
    
    app = FastAPI()
    
    
    @app.get("/")
    async def main():
        redirect_url = 'http://localhost:8000/callback_init?msg=Hello#code=1111&state=2222'
        response = RedirectResponse(redirect_url)
        response.set_cookie(key='some-cookie', value='some-cookie-value', httponly=True)
        return response
        
        
    @app.get('/callback_init', response_class=HTMLResponse)
    async def callback_init(request: Request):
        html_content = """
        <html>
           <head>
              <script>
                 var url = window.location.href;
                 const fragment = window.location.hash;
                 const searchParams = new URLSearchParams(fragment.substring(1));
                 url = url.replace('/callback_init', '/callback').replace(fragment, "");
                 const newUrl = new URL(url);            
                 for (const [key, value] of searchParams) {
                    newUrl.searchParams.append(key, value);
                }
                 window.location.replace(newUrl);
              </script>
           </head>
        </html>
        """
        return HTMLResponse(content=html_content, status_code=200)
    
    
    @app.get("/callback")
    async def callback(
        request: Request,
        response: Response,
        code: Optional[str] = None,
        state: Optional[str] = None,
    ):
        print(request.url.query)
        print(request.cookies)
        response.delete_cookie("some-cookie")
        return {"code": code, "state": state}