danestves / remix-auth-auth0

An Auth0Strategy for Remix Auth, based on the OAuth2Strategy
MIT License
115 stars 22 forks source link

Auth0 refresh tokens #104

Open wuurrd opened 10 months ago

wuurrd commented 10 months ago

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

Additional context

No response

ciyer commented 5 months ago

The author of the parent remix-auth framework has said that ensuring that access tokens are current is outside the scope of the framework (https://github.com/sergiodxa/remix-auth-oauth2/issues/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);