Open wuurrd opened 1 year 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);
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