cloudfront-authorization-at-edge: Issues with the refresh endpoint endlessly redirecting after signin

We’re using v2.1.0 (vanilla, nothing fancy/special). Occasionally, a user will get stuck in this redirect loop. I suspect it’s when the JWT expires, and a lazy evaluation happens. Not all the time, but sometimes, it will put the browser into this constant back and forth redirect ping thing that keeps showing the message/image displayed below. If you look at the XHR request stuff, you’ll see that it’s just endlessly redirecting from the /refresh endpoint, to the cognito /auth and /login endpoints. I can post the XHR’s (once I sanitize them), if needed.

This might be due to a refactor done recently for this version that has the JWT refreshed lazily perhaps?

I’ve tried with the JWT expiration set to 1 hour, 12 hours, 24 hours, and 5 minutes and the same thing. It just changes how fast the cycles go before this issue manifests/gets recreated.

For the end user, there’s no solution other than clearing cookies to correct this problem. Once they sign in again, the problem goes away for several refreshes, some unknown number (can’t recreate it consistently, it appears to be a timing thing) before it hits again.

I’m going to try reverting back to a previous version of the app (v2.0.19) to see if this has any positive impact.

image

About this issue

Most upvoted comments

@HudsonAkridge could you finally solve this issue? As it seems like we’re experiencing the same problem I would appreciate any hints how to approach this.

@ottokruse @james1050 I’ve verified that this works with everything, and I was able to remove Amplify from the configuration and it’s all good!

I’ll try and briefly type up the approach with a more detailed explanation later when I have more time. For now, this is some o fthe things done to make this work starting from a fresh deploy.

  1. Set the ARN for cognito, add the ClientId of your app pool, and set the Auth URL for Cognito to whatever the auth deployment endpoint is, in this case something like auth.yourdomain.com works for me.
  2. In the HttpHeaders section, REMOVE the Content-Security-Policy header section of the JSON completely. Leave the others in place.
  3. For the cookieSettings, the cookies need to change to become:
  "cookieSettings": {
        "idToken": "Path=/; Secure; Domain=yourdomain.com; SameSite=Lax",
        "accessToken": "Path=/; Secure; Domain=yourdomain.com; SameSite=Lax",
        "refreshToken": "Path=/; Secure; Domain=yourdomain.com; SameSite=Lax",
        "nonce": "Path=/; Secure; HttpOnly; Domain=yourdomain.com; SameSite=Lax"
    }

This should be the setting for all configuration.json files after deploy. You might need to manually update the Lambdas that have this and redeploy (RefreshAuthHandler, ParseAuthHandler, SignOutHandler, HttpHeadersHandler, CheckAuthHandler) if it doesn’t show as correct like the above.

  1. Leave defaults for all other options.

Now, in the frontend client code. Remove Amplify. Like, all of it. You shouldn’t need it because this repo @ottokruse and co have built pretty much does it all for you.

Create or modify the method used to get the JWT for your application’s headers (in my case, I need the JWT to attach to a call to api.yourdomain.com with Authorization: "Bearer <jwt>"). Here’s the final code that’s worked well for me in testing/trialing so far:

import Cookies from "universal-cookie";
import jwt_decode from 'jwt-decode';

const cookies = new Cookies();
const getIdTokenCookieValue = (obj, filter) => {
    let key = [];
    for (key in obj){
        if (Object.prototype.hasOwnProperty.call(obj, key) && filter.test(key)) {
            return key;
      }
    }
    return null;
  };

const rateLimitInMs = 15 * 1000; //15 seconds
const refreshTokenWindowInTicks = 15; //15 seconds
let lastBackgroundRefreshTimestamp = null;

export const getUserToken = async () => {
    let allCookies = cookies.getAll();
    
    //Calculate the idToken expiration. If this fails for some reason, we should be reloading/re-signing in to the app completely
    // so an error at this point should halt execution
    let initialIdTokenCookieName = getIdTokenCookieValue(allCookies, /idToken/);
    let initialIdToken = allCookies[initialIdTokenCookieName];
    let decodedToken = jwt_decode(initialIdToken);
    let initialIdTokenExpires = decodedToken.exp;
    let currentTicks = Math.floor(Date.now() / 1000); //Need to convert JSdate to Unix Ticks for comparisons
    let remainingIdTokenExpirationTicks = initialIdTokenExpires - currentTicks;
    let isWithinTokenExpirationWindow = remainingIdTokenExpirationTicks <= refreshTokenWindowInTicks;

    
    //Rate limit calls to doing a background fetch so we don't hammer this in case someone is impatient and keeps clicking
    let isNotRateLimited = !lastBackgroundRefreshTimestamp || (Date.now() - lastBackgroundRefreshTimestamp) >= rateLimitInMs;

    if(isNotRateLimited && isWithinTokenExpirationWindow)
    {
        lastBackgroundRefreshTimestamp = Date.now();
        let backgroundLoadResponse = await fetch("backgroundLoad.json", {cache: "no-store"});
        console.log("Refreshed page in background.");
    }

    //This may be a different idToken cookie value based on the Cognito Hash, we can't be sure it will match the one we retrieved above
    let currentIdTokenCookieName = getIdTokenCookieValue(allCookies, /idToken/);
    let idToken = allCookies[currentIdTokenCookieName];

    return idToken;
};

I’m not saying that the code above is the most optimized way to do this, or the cleanest, just that it’s fairly simple and it works.

Also, add the backgroundLoad.json file to your site, and include a .bundleignore file at the root of your project (if you’re using Yarn that is) and just put a single entry in it for backgroundLoad.json. This should prevent it from bundling that JSON file in with other javascript/json stuff so your file size is kept at small as possible. You’re only going to be refreshing that when it’s close to time for the token to expire, so it’s not going to be creating much traffic unless your token is set to expire insanely fast for some reason.

What should happen is that if a token is expired, it should do a fetch of that json file behind the scenes which will load the JSON file, hitting the route, firing off the /refreshauth lambda. Once that’s complete, it’ll fetch the updated idToken value and use that JWT value until it’s set to expire or close to an expiration.

This prevents hammering anything on the server and only executes it once, when needed.

Occasionally an individual request will fail on the background fetch, and you just have to click a link somewhere else in your app again (assuming it’s always calling this getUserToken method for every authentication related call), I haven’t been able to totally solve for that yet, but I haven’t had a lot of time to dig in to looking at it.

So far, so good.

I hope this helps someone else 🙂

One more thought.

If I understand your situation it is roughly this:

  1. You host a website (SPA) using this Auth@Edge solution
  2. Your website’s JavaScript calls APIs for which it uses the JWTs from the cookies
  3. The APIs are not fronted by the Auth@Edge CloudFront and to call them you need valid JWTs, therefore you’re looking for a method to trigger their refresh

If that’s indeed true, then a good way forward would be to change 3 above: front the APIs also with the Auth@Edge CloudFront. Then refreshes will be seamless our of the box, as you’ve seen Auth@Edge redirects automatically to the refresh endpoint and back to the requested location. So then you don’t need to trigger token refresh manually in your SPA. Side benefit is that this also eliminates CORS preflight requests, so better latency.