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

Question - JWT Token Expiring Error When Querying Supabase Directly #39

Closed
aaronksaunders opened this issue Aug 4, 2022 · 11 comments
Closed
Labels
question Further information is requested

Comments

@aaronksaunders
Copy link

First off thank you for the application, I am using it as a reference for a solution I am creating.

I am running into a problem where I am getting an error saying

[1] { message: 'JWT expired', code: 'PGRST301', details: null, hint: null }

when doing queries in my application. I am confused because the call to getAuthSession is returning a valid session but when get the supabase client

    const supabaseClient = supabase(authSession?.accessToken as string);

and make a database call, it errors out

@rphlmr rphlmr added the question Further information is requested label Aug 4, 2022
@rphlmr
Copy link
Owner

rphlmr commented Aug 4, 2022

Hello, thanks for your kind words :)

The best way to get accessToken (in my stack) is to use requireAuthSession instead of getAuthSession directly (I guess it's what you did ?).
requireAuthSession internally calls getAuthSession but does so much more like refreshing tokens and saving new tokens in session cookie (for HTTP GET only, like loader function).
It throws if It's not possible to refresh.

Example :

const { userId, email, accessToken } = await requireAuthSession(request);
const { data: notes, error } = await supabase(accessToken)
.from("Note")
.select("id, title");

That's for loader function.

For action function, you have to do more and commit your authSession every time.
Example :

if (!newNote || error) {
throw json(
{ error: "server-error-creating-note" },
{
status: 500,
headers: {
"Set-Cookie": await commitAuthSession(request, { authSession }),
},
}
);
}
return redirect(`/rls/notes/${newNote.id}`, {
headers: {
"Set-Cookie": await commitAuthSession(request, { authSession }),
},
});

That's because, requireAuthSession :

  • could return new accessToken and refrechToken when token expires or expires soon (10 minutes, custom threshold)
  • for loader, requireAuthSession handles a redirect (like a reload) and saves new tokens for you with a redirect to request.url (because GET is safe to redirect).
  • for action, it's not safe / possible to redirect / replay, so, developer have to commit session when returns from action function.

Starting point : https://github.com/rphlmr/supa-fly-stack/blob/main/app/core/auth/guards/require-auth-session.server.ts
Then :

export async function refreshAuthSession(
request: Request
): Promise<AuthSession> {
const authSession = await getAuthSession(request);
const refreshedAuthSession = await refreshAccessToken(
authSession?.refreshToken
);
// 👾 game over, log in again
// yes, arbitrary, but it's a good way to don't let an illegal user here with an expired token
if (!refreshedAuthSession) {
const redirectUrl = `${LOGIN_URL}?${makeRedirectToFromHere(request)}`;
// here we throw instead of return because this function promise a AuthSession and not a response object
// https://remix.run/docs/en/v1/guides/constraints#higher-order-functions
throw redirect(redirectUrl, {
headers: {
"Set-Cookie": await commitAuthSession(request, {
authSession: null,
flashErrorMessage: "fail-refresh-auth-session",
}),
},
});
}
// refresh is ok and we can redirect
if (isGet(request)) {
// here we throw instead of return because this function promise a UserSession and not a response object
// https://remix.run/docs/en/v1/guides/constraints#higher-order-functions
throw redirect(getCurrentPath(request), {
headers: {
"Set-Cookie": await commitAuthSession(request, {
authSession: refreshedAuthSession,
}),
},
});
}
// we can't redirect because we are in an action, so, deal with it and don't forget to handle session commit 👮‍♀️
return refreshedAuthSession;
}

Let me know if it solves your issue or if I'm totally wrong ;)

@msevestre
Copy link

msevestre commented Aug 4, 2022

A question on this: isn't it enough to use the requireAuthSession in the loader? Why do you actually need to do this in your action?

If you need the userId, you could have a method that can retrieve it from the request and simply throw if it's not available?

@aaronksaunders
Copy link
Author

aaronksaunders commented Aug 4, 2022 via email

@msevestre
Copy link

Yes but wouldn't the loader kick in before?

@rphlmr
Copy link
Owner

rphlmr commented Aug 4, 2022

A question on this: isn't it enough to use the requireAuthSession in the loader? Why do you actually need to do this in your action?

If you need the userId, you could have a method that can retrieve it from the request and simply throw if it's not available?

Hi, loader and action have to be treated independently since anything can post directly on your action without triggering loader (like a fetcher from useFetcher, external fetch, ...).
They are independent endpoints in the end.

@msevestre
Copy link

Sure. But in the case of the notes, you could throw if the authSession was invalid no? I understand what the aim is but I am wondering if this is required

@msevestre
Copy link

I'll post a code snippet tomorrow

@msevestre
Copy link

ok so this is what I meant:

what about having two sets of methods:

for Loader

We use requireAuthSession. This ensures that we actually have a session and that we can refresh the session if it is expiring etc

for Actions (POST, PUT,etc...)

We use something relying on getAuthSession but that would throw an error if it's not valid.

something like this

export async function getCurrentAuthSession(request: Request) {
  const authSession = await getAuthSession(request);
  // If there is no user session, Fly, You Fools! 🧙‍♂️
  if (authSession?.accessToken && authSession?.refreshToken) {
    return authSession;
  }
  throw new Error("no_user_session");
}

Problem with this: This does not refresh the session on action. But with your latest PR, where the token is refreshed a few minutes before expiring, this should prevent issues with token expiring while the action is performed.

This way, the action code is much leaner and you do not have to deal with sessions etc in in your action code.
Thoughts?

@rphlmr
Copy link
Owner

rphlmr commented Aug 5, 2022

Your action can be called by something else, outside your app (curl, malicious app, etc.).

You can do that, but getAuthtSession doesn't verify your accessToken with Supabase.

If you use Prisma and not supabase client with RLS (Prisma bypass RLS security), it's a security issue since it's possible to steal network requests (corp proxy, malicious add-on, public Wi-Fi, ...).


Example :
First, to not wait 50 min before refresh, set JWT expiry limit to 10 in your supabase dashboard
image and change REFRESH_THRESHOLD to 0.
What we want to test here is how the code reacts when accessToken is expired.

Iin routes/notes/new.tsx I have this action :

export async function action({ request }: LoaderArgs) {
  assertIsPost(request);

  const authSession = await getAuthSession(request);
  if (!authSession) throw new Error("fail");

  console.log(authSession);

  const formValidation = await getFormData(request, NewNoteFormSchema);

 ...
}
  1. Open network inspector and make some new notes.
  2. Take one of the POST requests, right click, copy as cURL

image

  1. Logout.

At this time, 10s later, accessToken in cURL should be expired.

  1. Run the curl in a terminal.
  2. Log in and you should see unwanted notes.

Retry this process with requireAuthSession, no unwanted notes.

👇 (record)

Enregistrement.de.l.ecran.2022-08-05.a.18.16.06.mp4

To avoid this, you should verify accesToken with Supabase, but it's like using requireAuthSession and do not commit its result.

I think that if it's the "commit session" part that you want to remove, keep requireAuthSession or rewrite a version without refresh :

export async function action({ request }: LoaderArgs) {
  assertIsPost(request);

  const authSession = await requireAuthSession(request);
  const formValidation = await getFormData(request, NewNoteFormSchema);

  if (!formValidation.success) {
    return json(
      {
        errors: {
          title: formValidation.errors.title,
          body: formValidation.errors.body,
        },
      },
      {
        status: 400,
      }
    );
  }

  const { title, body } = formValidation.data;

  const note = await createNote({ title, body, userId: authSession.userId });

  return redirect(`/notes/${note.id}`);
}

If you use supabase client with RLS, then, you should not have a security issue because it'll reject your accessToken


The only cons I can see by removing "refresh on action "and "commit session" is the case where user is idle (going to lunch :D), come back and submit to your action without refreshing before => logout and form content lost

@msevestre
Copy link

Hey @rphlmr
Yes you are right. The verify session with supabase needs to happen indeed. I think this is a good compromise
Thanks so much for the amazing answer 😊

@rphlmr
Copy link
Owner

rphlmr commented Aug 9, 2022

Feel free to reopen as I made some changes since the day you open this issue 😬

@rphlmr rphlmr closed this as completed Aug 9, 2022
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
question Further information is requested
Projects
None yet
Development

No branches or pull requests

3 participants