Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Auth0 refresh tokens #104

Open
wuurrd opened this issue Nov 16, 2023 · 1 comment
Open

Auth0 refresh tokens #104

wuurrd opened this issue Nov 16, 2023 · 1 comment

Comments

@wuurrd
Copy link

wuurrd commented Nov 16, 2023

Describe the bug

If you enable refresh tokens and your token expires isAuthenticated returns true and authenticate doesn't re-authenticate (or use the refresh token to) I can't seem to find any explicit support for it. Are there any working examples of it? Spotify auth seems to handle it like:

https://github.com/JosteinKringlen/remix-auth-spotify/blob/3987237f49c29047ff27e75225686320c1e08ac7/src/index.ts#L263

Your Example Website or App

Steps to Reproduce the Bug or Issue

Enable refresh tokens

Expected behavior

Tokens should be refreshed automatically

Screenshots or Videos

No response

Platform

  • OS: [e.g. macOS, Windows, Linux]
  • Browser: [e.g. Chrome, Safari, Firefox]
  • Version: [e.g. 91.1]

Additional context

No response

@ciyer
Copy link

ciyer commented Mar 20, 2024

The author of the parent remix-auth framework has said that ensuring that access tokens are current is outside the scope of the framework (sergiodxa/remix-auth-oauth2#37), so I do not expect this to be something that is implemented within the framework itself.

That being said, it isn't too hard to do yourself. You can implement a subclass of the Auth0Strategy that handles this. Make sure that you are taking security precautions around the handling of refresh tokens, since it is important to avoid them being compromised and detecting if that has happened (e.g., https://auth0.com/docs/secure/tokens/refresh-tokens).

The code will look something like this:

import { SessionStorage, json, redirect } from "@remix-run/server-runtime";
import { AuthenticateOptions, Authenticator } from "remix-auth";
import { Auth0Strategy, Auth0StrategyDefaultScope } from "remix-auth-auth0";
import jwt from "jsonwebtoken";
import createDebug from "debug";

import { sessionStorage } from "./session.server";
import config from "./config.server";

// The remix-auth framework will not try to keep tokens up-to-date
//   https://github.com/sergiodxa/remix-auth-oauth2/issues/37
// So we do it ourselves. Make sure that the session information
// is stored in a secure way (either on the server, or with precautions
// on the client side).
const debug = createDebug("Auth0StrategyWithTokenRefresh");


type UserWithRefreshToken = {
  idToken: string | undefined;
  refreshToken: string | undefined;
};

async function clearSession(
  session: Awaited<ReturnType<typeof sessionStorage.getSession>>,
  sessionStorage: SessionStorage,
  options: AuthenticateOptions
): Promise<never> {
  const message = "Re-login required";
  const headers = {
    "Set-Cookie": await sessionStorage.destroySession(session)
  };
  // if a failureRedirect is not set, we throw a 401 Response
  if (!options.failureRedirect) {
    throw json({ message }, { headers, status: 401 });
  }

  throw redirect(options.failureRedirect, { headers });
}

function isTokenValid(token: string) {
  // Check if the token is still valid
  const decodedToken = jwt.decode(token, { complete: true });
  if (!decodedToken) {
    debug("Could not decode token", token);
    return false;
  }
  if (
    typeof decodedToken.payload !== "object" ||
    decodedToken.payload.exp == null
  ) {
    // No need to check if the token is still valid
    return true;
  }
  // exp is seconds (not ms) since epoch
  const now = Math.floor(Date.now() / 1000);
  if (decodedToken.payload.exp < now) {
    debug("Token expired", decodedToken.payload);
    return false;
  }
  return true;
}

class Auth0StrategyWithTokenRefresh<
  IUser extends UserWithRefreshToken
> extends Auth0Strategy<IUser> {
  async authenticate(
    request: Request,
    sessionStorage: SessionStorage,
    options: AuthenticateOptions
  ): Promise<IUser> {
    const session = await sessionStorage.getSession(
      request.headers.get("Cookie")
    );

    let user: IUser | null = session.get(options.sessionKey) ?? null;
    if (user == null) {
      // Handle this call as in the superclass
      return super.authenticate(request, sessionStorage, options);
    }

    const code = user.refreshToken;
    if (code == null) {
      // No refresh token, nothing we can do; remove the expired session 
      return clearSession(session, sessionStorage, options);
    }

    // Request a new access token using the refresh token
    const params = new URLSearchParams(this.tokenParams());
    params.set("grant_type", "refresh_token");
    const { accessToken, refreshToken, extraParams } =
      await this.fetchAccessToken(code, params);
    // Get the profile
    const profile = await this.userProfile(accessToken);
    // Verify the user and return it, or redirect

    try {
      user = await this.verify({
        accessToken,
        refreshToken,
        extraParams,
        profile,
        context: options.context,
        request
      });
    } catch (error) {
      debug("Failed to verify user", error);
      // Allow responses to pass-through
      if (error instanceof Response) throw error;
      if (error instanceof Error) {
        return await this.failure(
          error.message,
          request,
          sessionStorage,
          options,
          error
        );
      }
      if (typeof error === "string") {
        return await this.failure(
          error,
          request,
          sessionStorage,
          options,
          new Error(error)
        );
      }
      return await this.failure(
        "Unknown error",
        request,
        sessionStorage,
        options,
        new Error(JSON.stringify(error, null, 2))
      );
    }

    debug("User authenticated");
    return await this.success(user, request, sessionStorage, options);
  }
}

class AuthenticatorWithTokenRefresh<
  T extends UserWithRefreshToken
> extends Authenticator<T> {
  async isAuthenticatedOrReauthenticate(request: Request) {
    let authInfo = await this.isAuthenticated(request, {
      failureRedirect: "/login"
    });
    if (authInfo.idToken == null) {
      throw redirect("/login");
    }

    if (!isTokenValid(authInfo.idToken)) {
      authInfo = await this.authenticate("auth0", request);
      if (authInfo.idToken == null) {
        throw redirect("/login");
      }
    }

    return authInfo;
  }
}

const auth0Strategy = new Auth0StrategyWithTokenRefresh(
  {
    callbackURL: config.auth0.callbackURL,
    clientID: config.auth0.clientID,
    clientSecret: config.auth0.clientSecret,
    domain: config.auth0.domain,
    // https://auth0.com/docs/secure/tokens/refresh-tokens/get-refresh-tokens
    // https://openid.net/specs/openid-connect-core-1_0.html#OfflineAccess
    // Comment out the following line if you do not want tokens to be refreshed
    scope: [Auth0StrategyDefaultScope, "offline_access"]
  },
  async ({ accessToken, refreshToken, extraParams, profile }) => {
    return {
      idToken: extraParams.id_token,
      refreshToken: refreshToken,
      user: {
        // whatever you want to store about the user here
      }
    };
  }
);

// Create an instance of the authenticator, pass a generic with what your
// strategies will return and will be stored in the session
const authenticator = new AuthenticatorWithTokenRefresh<AuthInfo>(
  sessionStorage
);
// add a method to authenticator
authenticator.use(auth0Strategy);

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

2 participants