How to redirect from one domain to another and set cookies or headers for the other domain?
Asked Answered
M

1

10

I am using FastAPI's RedirectResponse and trying to redirect the user from one application (domain) to another with some cookie set in the response; however, the cookie always gets deleted/not transferred. If I try to add some headers, all the headers that I add to the RedirectResponse are not transferred either.

@router.post("/callback")
async def sso_callback(request: Request):
   jwt_token = generate_token(request)
   redirect_response = RedirectResponse(url="http://192.168.10.1/app/callback", 
                             status_code=303)
   redirect_response.set_cookie(key="accessToken", value=jwt_token, httponly=True)
   redirect_response.headers["Authorization"] = str(jwt_token)
   return redirect_response

How can I solve this? Thanks in advance for the help.

Mycenaean answered 30/8, 2022 at 19:50 Comment(0)
M
21

As described here you cannot redirect to another domain with custom headers set, no matter what language or framework you use. A redirection in the HTTP protocol is basically a header (i.e., Location) associated with the response, and it doesn't allow for any headers to the target location to be added. When you add the Authorization header in your example, you basically set that header for the response which is instructing the browser to redirect, not for the redirect itself. In other words, you are sending that header back to the client.

As for the HTTP cookies, the browser stores the cookies sent by the server with the response (using the Set-Cookie header), and later sends the cookies with requests made to the same server inside a Cookie HTTP header. As per the documentation:

The Set-Cookie HTTP response header is used to send a cookie from the server to the user agent, so that the user agent can send it back to the server later. To send multiple cookies, multiple Set-Cookie headers should be sent in the same response.

Hence, if this was a redirection from one app (with sub-domain, e.g., abc.example.test) to another (with sub-domain, e.g., xyz.example.test) that both have the same (parent) domain (and the domain flag was set to example.test when creating the cookies), cookies would be successfully shared between the two apps (as if domain is specified, then subdomains are always included). The browser will make a cookie available to the given domain including any sub-domains, no matter which protocol (HTTP/HTTPS) or port is used. You can limit a cookie's availability using the domain and path flags, as well as restrict access to the cookie with secure and httpOnly flags (see here and here, as well as Starlette documentation). If the httpOnly flag isn't set, a potential attacker can read and modify the information through JavaScript (JS), whereas a cookie with the httpOnly attribute is only sent to the server, and is inaccessible to JS on client side.

However, you cannot set cookies for a different domain. If this was permitted, it would present an enormous security flaw. Hence, since you are "trying to redirect the user from one application (domain) to another with some cookie set,...", it wouldn't work, as the cookie will only be sent with requests made to the same domain.

Solution 1

A solution, as described here, is to have domain (app) A redirecting the user to domain (app) B, with the access-token passed in the URL as a query parameter. Domain B would then read the token and set its own cookie, so that the browser will store and send that cookie with every subsequent request to domain B.

Please note that you should consider using a secure (HTTPS) communication, so that the token is transferred encrypted, as well as setting the secure flag when creating the cookie. Also, note that having the token in the query string poses a serious security risk, as sensitive data should never be passed in the query string. This is because the query string, which is part of the URL, appears in the address bar of the browser; thus, allowing the user to see and bookmark the URL with the token in it (meaning that it is saved on the disk). Also, the URL will make it to the browsing history, which means it will be written to the disk anyway and appear in the History tab (press Ctrl+H to see the browser's history). Both the above would allow attackers (and people you share the computer/mobile device with) to steal such sensitive data. Additionally, many browser plugins/extensions track users' browsing activity—every URL you visit is sent to their servers for analysis, in order to detect malicious websites and warn you beforehand. Hence, you should take all the above into consideration before using the approach below (for related posts on this subject, see here, here and here).

To prevent displaying the URL in the address bar, the approach below uses a redirection within domain B as well. Once domain B receives the request to the /submit route with the token as a query parameter, domain B responds with a redirection to a bare URL with no tokens in it (i.e., its home page). Because of this redirection, the URL with the token in it wouldn't end up in the browsing history. Although this provides some protection against certain attacks described earlier, it doesn't mean that browser extensions, etc., won't still be able to capture the URL with the token in it.

If you are testing this on localhost, you need to give application B a different domain name; otherwise, as mentioned earlier, cookies will be shared between applications having the same domain, and hence, you would end up receiving the cookies set for domain A, and couldn't tell if the approach is working at all. To do that, you have to edit the /etc/hosts file (on Windows this is located in C:\Windows\System32\drivers\etc) and assign a hostname to 127.0.0.1. For example:

127.0.0.1 example.test

You shouldn't add the scheme or port to the domain, as well as shouldn't use common extensions, such as .com, .net, etc., otherwise it may conflict with accessing other websites on the Internet.

Once you access domain A below, you will need to click on the submit button to perform a POST request to the /submit route to start the redirection. The only reason for the POST request is because you are using it in your example and I am assuming you have to post some form-data. Otherwise, you could use a GET request as well. In app B, when performing a RedirectResponse from a POST route (i.e., /submit) to a GET route (i.e., /), the response status code changes to status.HTTP_303_SEE_OTHER, as described here, here and here. App A is listening on port 8000, while app B is listening on port 8001.

Run both apps below, and then access domain A at http://127.0.0.1:8000/.

appA.py

from fastapi import FastAPI
from fastapi.responses import RedirectResponse, HTMLResponse
import uvicorn

app = FastAPI()
           
@app.get('/', response_class=HTMLResponse)
def home():
    return """
    <!DOCTYPE html>
    <html>
       <body>
          <h2>Click the "submit" button to be redirected to domain B</h2>
          <form method="POST" action="/submit">
             <input type="submit" value="Submit">
          </form>
       </body>
    </html>
    """
        
@app.post("/submit")
def submit():
    token = 'MTQ0NjJkZmQ5OTM2NDE1ZTZjNGZmZjI3'
    redirect_url = f'http://example.test:8001/submit?token={token}'
    response = RedirectResponse(redirect_url)
    response.set_cookie(key='access-token', value=token, httponly=True)  # set cookie for domain A too
    return response
 
if __name__ == '__main__':
    uvicorn.run(app, host='0.0.0.0', port=8000)

appB.py

from fastapi import FastAPI, Request, status
from fastapi.responses import RedirectResponse
import uvicorn

app = FastAPI()

@app.get('/')
def home(request: Request):
    token = request.cookies.get('access-token')
    print(token)
    return 'You have been successfully redirected to domain B!' \
           f' Your access token ends with: {token[-4:]}'
 
@app.post('/submit')
def submit(request: Request, token: str):
    redirect_url = request.url_for('home')
    response = RedirectResponse(redirect_url, status_code=status.HTTP_303_SEE_OTHER)
    response.set_cookie(key='access-token', value=token, httponly=True)
    return response
 
if __name__ == '__main__':
    uvicorn.run(app, host='0.0.0.0', port=8001)

Solution 2

Another solution would be to use Window.postMessage(), which enables cross-origin communication between Window objects; for example, between a page and a pop-up that it spawned, or between a page and an iframe embedded within it. Examples on how to add event listeners and communicate between the windows can be found here. The steps to follow would be:

Step 1: Add to domain A a hidden iframe to domain B. For example:

<iframe id="cross_domain_page" src="http://example.test:8001" frameborder="0" scrolling="no" style="background:transparent;margin:auto;display:block"></iframe>

Step 2: As soon as you obtain the Authorization token from the headers of an asynchronous JS request to domain A, send it to domain B. For example:

document.getElementById('cross_domain_page').contentWindow.postMessage(token,"http://example.test:8001");

Step 3: In domain B, receive the token through window.addEventListener("message", (event) ... , and store it in localStorage:

localStorage.setItem('token', event.data);

or, in a cookie using JS (not recommended, see notes below):

document.cookie = `token=${event.data}; path=/; SameSite=None; Secure`;

Step 4: Message domain A that the token has been stored, and then redirect the user to domain B.


Note 1: Step 3 demonstrates how to set a cookie using JS, but you shouldn't really use JS when you are about to store such sensitive information, as cookies created via JS can't include the HttpOnly flag, which helps mitigate cross-site scripting (XSS) attacks. This means that attackers who may have injected malicious scripts into your website would be able to access the cookie. You should rather let the server set the cookie (through a fetch request), including the HttpOnly flag (as shown in the example below), thus making the cookie inaccessible to the JS Document.cookie API. The localStorage is also susceptible to XSS attacks, as the data are also accessible via JS (e.g., localStorage.getItem('token')).

Note 2: For this solution to work, users must have the option Allow all cookies enabled in their browsers—that many users don't, as well as some browsers exclude third-party cookies by default (Safari and In Private mode of Chrome are known for rejecting these cookies by default)—as the content is being loaded into an iframe from a different domain, and thus the cookie is classed as a third-party cookie. The same applies to using localStorage as well (i.e., Allow all cookies must be enabled for being able to use it through an iframe). Using cookies in this case, however, you would also need to set the SameSite flag to None, as well as the cookie should include the Secure flag, which is required in order to use SameSite=None. This means that the cookie will only be sent over HTTPS connections; this won't mitigate all risks associated with cross-site access, but it will provide protection against network attacks (if your server does not run over HTTPS, for demo purposes only, you can use the 'Insecure origins treated as secure' experimental feature at chrome://flags/ in Chrome browser). Setting SameSite=None means that the cookie would not be protected from external access, and you should thus be aware of the risks before using it.

Example using iframe and SameSite=None; Secure; HttpOnly cookie

Run both apps below, and then access domain A at http://127.0.0.1:8000/.

appA.py

from fastapi import FastAPI, Request, Response
from fastapi.responses import HTMLResponse

app = FastAPI()

@app.get('/', response_class=HTMLResponse)
def home():
    return """
    <!DOCTYPE html>
    <html>
       <body>
          <h2>Click the "submit" button to be redirected to domain B</h2>
          <input type="button" value="Submit" onclick="submit()"><br>
          <iframe id="cross_domain_page" src="http://example.test:8001/iframe"  frameborder="0" scrolling="no" style="background:transparent;margin:auto;display:block"></iframe>
          <script>
             function submit() {
                fetch('/submit', {
                     method: 'POST',
                  })
                  .then(res => {
                     authHeader = res.headers.get('Authorization');
                     if (authHeader.startsWith("Bearer "))
                        token = authHeader.substring(7, authHeader.length);
                     return res.text();
                  })
                  .then(data => {
                     document.getElementById('cross_domain_page').contentWindow.postMessage(token, "http://example.test:8001");
                  })
                  .catch(error => {
                     console.error(error);
                  });
             }
             
             window.addEventListener("message", (event) => {
                if (event.origin !== "http://example.test:8001")
                  return;
             
                if (event.data == "cookie is set")
                  window.location.href = 'http://example.test:8001/';
             }, false);
          </script>
       </body>
    </html>
    """

@app.post('/submit')
def submit():
    token = 'MTQ0NjJkZmQ5OTM2NDE1ZTZjNGZmZjI3'
    headers = {'Authorization': f'Bearer {token}'}
    response = Response('success', headers=headers)
    response.set_cookie(key='access-token', value=token, httponly=True)  # set cookie for domain A too
    return response
    
if __name__ == '__main__':
    import uvicorn
    uvicorn.run(app, host='0.0.0.0', port=8000)

appB.py

from fastapi import FastAPI, Request, Response
from fastapi.responses import HTMLResponse

app = FastAPI()

@app.get('/iframe', response_class=HTMLResponse)
def iframe():
    return """
    <!DOCTYPE html>
    <html>
       <head>
          <script>
             window.addEventListener("message", (event) => {
                if (event.origin !== "http://127.0.0.1:8000")
                   return;
             
                fetch('/submit', {
                      method: 'POST',
                      headers: {
                         'Authorization': `Bearer ${event.data}`
                      }
                   })
                   .then(res => res.text())
                   .then(data => {
                      event.source.postMessage("cookie is set", event.origin);
                   })
                   .catch(error => {
                      console.error(error);
                   })
             }, false);
          </script>
       </head>
    </html>
    """
    
@app.get('/')
def home(request: Request):
    token = request.cookies.get('access-token')
    print(token)
    return 'You have been successfully redirected to domain B!' \
           f' Your access token ends with: {token[-4:]}'

@app.post('/submit')
def submit(request: Request):
    authHeader = request.headers.get('Authorization')
    if authHeader.startswith("Bearer "):
        token = authHeader[7:]
    response = Response('success')
    response.set_cookie(key='access-token', value=token, samesite='none', secure=True, httponly=True) 
    return response
    
if __name__ == '__main__':
    import uvicorn
    uvicorn.run(app, host='0.0.0.0', port=8001)

Example using iframe and localStorage

This example demonstrates an approach using localStorage this time to store the token. As soon as the token is stored, domain A redirects the user to /redirect route of domain B; domain B then retrieves the token from the localStorage (and subsequently removes it from the localStorage), and later sends it to its own /submit route, in order to set an httpOnly cookie for access-token. Finally, the user is redirected to the home page of domain B.

appA.py

from fastapi import FastAPI, Request, Response
from fastapi.responses import HTMLResponse

app = FastAPI()
    
@app.get('/', response_class=HTMLResponse)
def home():
    return """
    <!DOCTYPE html>
    <html>
       <body>
          <h2>Click the "submit" button to be redirected to domain B</h2>
          <input type="button" value="Submit" onclick="submit()"><br>
          <iframe id="cross_domain_page" src="http://example.test:8001/iframe"  frameborder="0" scrolling="no" style="background:transparent;margin:auto;display:block"></iframe>
          <script>
             function submit() {
                fetch('/submit', {
                     method: 'POST',
                  })
                  .then(res => {
                     authHeader = res.headers.get('Authorization');
                     if (authHeader.startsWith("Bearer "))
                        token = authHeader.substring(7, authHeader.length);
                     return res.text();
                  })
                  .then(data => {
                     document.getElementById('cross_domain_page').contentWindow.postMessage(token, "http://example.test:8001");
                  })
                  .catch(error => {
                     console.error(error);
                  });
             }
             
             window.addEventListener("message", (event) => {
                if (event.origin !== "http://example.test:8001")
                  return;
             
                if (event.data == "token stored")
                  window.location.href = 'http://example.test:8001/redirect';
             }, false);
                
          </script>
       </body>
    </html>
    """

@app.post('/submit')
def submit():
    token = 'MTQ0NjJkZmQ5OTM2NDE1ZTZjNGZmZjI3'
    headers = {'Authorization': f'Bearer {token}'}
    response = Response('success', headers=headers)
    response.set_cookie(key='access-token', value=token, httponly=True)  # set cookie for domain A too
    return response
    
if __name__ == '__main__':
    import uvicorn
    uvicorn.run(app, host='0.0.0.0', port=8000)

appB.py

from fastapi import FastAPI, Request, Response
from fastapi.responses import HTMLResponse

app = FastAPI()

@app.get('/iframe', response_class=HTMLResponse)
def iframe():
    return """
    <!DOCTYPE html>
    <html>
       <head>
          <script>
             window.addEventListener("message", (event) => {
                if (event.origin !== "http://127.0.0.1:8000")
                   return;
             
                localStorage.setItem('token', event.data);
                event.source.postMessage("token stored", event.origin);
             }, false);
          </script>
       </head>
    </html>
    """
 
@app.get('/redirect', response_class=HTMLResponse)
def redirect():
    return """
    <!DOCTYPE html>
    <html>
       <head>
          <script>
            const token = localStorage.getItem('token');
            localStorage.removeItem("token");   
            fetch('/submit', {
                  method: 'POST',
                  headers: {
                     'Authorization': `Bearer ${token}`
                  }
               })
               .then(res => res.text())
               .then(data => {
                  window.location.href = 'http://example.test:8001/';
               })
               .catch(error => {
                  console.error(error);
               })
          </script>
       </head>
    </html>
    """
    
    
@app.get('/')
def home(request: Request):
    token = request.cookies.get('access-token')
    print(token)
    return 'You have been successfully redirected to domain B!' \
           f' Your access token ends with: {token[-4:]}'

@app.post('/submit')
def submit(request: Request):
    authHeader = request.headers.get('Authorization')
    if authHeader.startswith("Bearer "):
        token = authHeader[7:]
    response = Response('success')
    response.set_cookie(key='access-token', value=token, httponly=True) 
    return response
  
   
if __name__ == '__main__':
    import uvicorn
    uvicorn.run(app, host='0.0.0.0', port=8001)

Solution 3

Have a look here and here to see how StackExchange's auto-login works; for example, StackExchange (SE) automatically logs you in when you're already logged in to StackOverflow (SO). In brief, they don't use third-party cookies, but localStorage instead combined with global authentication at their centralised domain http://stackauth.com. Even though third-party cookies are not used, they do use iframe, as noted here, to store the token in localStorage. That means, this approach will only work if users' browser accepts third-party cookies (as described in Solution 2). As explained in Solution 2, even if you are accessing localStorage and not document.cookie within an iframe embedded in the main window, you still need users to have Allow all cookies enabled in their browser settings; otherwise, it wouldn't work and users would be asked to log in again, if they attempted to access any other site in the SE network.

Update

The approach described above is how SE's auto-login used to work in the past. Nowadays, the approach differs a little bit, as described in a more recent post, which atually describes the way SE's universal login works today (you can verify the process by inspecting the Network activity in the DevTools of your browser, while logging in to one of the SE sites; for instance, SO).

The way it works is by injecting <img> tags pointing to the other Stack Exchange sites (i.e., serverfault.com, superuser.com, etc.) when you log in to one of the SE sites. The src URL of these <img> tags includes a unique authToken as a query parameter that is generated by a universal authentication system and obtained through an XMLHttpRequest POST request. An example of these <img> tags would be the following:

<img src="https://serverfault.com/users/login/universal.gif?authToken=<some-token-value>&nonce=<some-nonce-value>" />

You browser will then send that src URL (with the authToken in it) to each of the other sites (that you're currently not on), and in response of that image, two cookies will be returned for every given domain/site: prov and acct. When you later switch to one of the other SE sites, your browser will send the cookies prov and acct that you received earlier, in order for the site to validate the token and (if valid) log you in.

Note: For this to work, your browser needs to accept third-party cookies (as desctibed earlier), as the cookie has to be set with the SameSite=None; Secure flags (be aware of the risks mentioned above). Without allowing third-party cookies—as well as without running your server over HTTPS—universal auto-login won't work. Also, the other domain for which you are trying to set cookies needs to have CORS enabled, as when the img gets loaded from a different domain, a cross-origin request is performed. Moreover, as this approach sends the authToken in the query parameters of the URL (even though it takes place in the background and not in the address bar of the browser), you should be aware of the risks described earlier in Solution 1.

The below uses an <img> tag pointing to domain B. The img URL doesn't have to be an actual image for the server to receive the access-token, and thus, you can use the .onerror() function to check when the request is actually completed (meaning that the server has responded with the Set-Cookie header), so that you can redirect the user to domain B.

One could instead use a fetch request to domain B with the access-token in the Authorization header, and the server can respond similarly to set the cookie. In that case, make sure to use credentials: 'include' and mode: 'cors', as well as explicitly specify the allowed origins on server side, as described here.

Run both apps below, and subsequently access domain A at http://127.0.0.1:8000/.

appA.py

from fastapi import FastAPI, Response
from fastapi.responses import HTMLResponse

app = FastAPI()

@app.get('/', response_class=HTMLResponse)
def home():
    return """
    <!DOCTYPE html>
    <html>
       <body>
          <h2>Click the "submit" button to be redirected to domain B</h2>
          <input type="button" value="Submit" onclick="submit()"><br>
          <script>
            function submit() {
               fetch('/submit', {
                     method: 'POST',
                  })
                  .then(res => {
                     authHeader = res.headers.get('Authorization');
                     if (authHeader.startsWith("Bearer "))
                        token = authHeader.substring(7, authHeader.length);

                     return res.text();
                  })
                  .then(data => {
                     var url = 'http://example.test:8001/submit?token=' + encodeURIComponent(token);
                     var img = document.createElement('img');
                     img.style = 'display:none';
                     img.crossOrigin = 'use-credentials'; // needed for CORS
                     img.onerror = function(){
                        window.location.href = 'http://example.test:8001/';
                     }
                     img.src = url;
                  })
                  .catch(error => {
                     console.error(error);
                  });
            }
          </script>
       </body>
    </html>
    """
    
@app.post('/submit')
def submit():
    token = 'MTQ0NjJkZmQ5OTM2NDE1ZTZjNGZmZjI3'
    headers = {'Authorization': f'Bearer {token}'}
    response = Response('success', headers=headers)
    response.set_cookie(key='access-token', value=token, httponly=True)  # set cookie for domain A too
    return response
    
if __name__ == '__main__':
    import uvicorn
    uvicorn.run(app, host='0.0.0.0', port=8000)

appB.py

from fastapi import FastAPI, Request, Response
from fastapi.responses import RedirectResponse
from fastapi.middleware.cors import CORSMiddleware

app = FastAPI()

origins = ['http://localhost:8000', 'http://127.0.0.1:8000',
           'https://localhost:8000', 'https://127.0.0.1:8000'] 

app.add_middleware(
    CORSMiddleware,
    allow_origins=origins,
    allow_credentials=True,
    allow_methods=["*"],
    allow_headers=["*"],
)

@app.get('/')
def home(request: Request):
    token = request.cookies.get('access-token')
    print(token)
    return 'You have been successfully redirected to domain B!' \
           f' Your access token ends with: {token[-4:]}'
 
@app.get('/submit')
def submit(request: Request, token: str):
    response = Response('success')
    response.set_cookie(key='access-token', value=token, samesite='none', secure=True, httponly=True) 
    return response

if __name__ == '__main__':
    import uvicorn
    uvicorn.run(app, host='0.0.0.0', port=8001)
Munitions answered 4/9, 2022 at 12:11 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.