supertokens / supertokens-auth-react

ReactJS authentication module for SuperTokens
https://supertokens.com
Other
260 stars 81 forks source link

SSR support for SessionAuth #790

Open sasha240100 opened 5 months ago

sasha240100 commented 5 months ago

TODOs:

Key takeaways about SSR support for SessionAuth

  1. For support of SSR, SessionAuth needs to handle an initial context (initialSessionAuthContext property from https://github.com/supertokens/supertokens-auth-react/pull/789).
  2. There are several ways of SSR implementation and they differ a lot. The most popular (NextJS), [with App Router] separates server components (execute on server only) from client components (execute on server [partially] & client) and there are many restrictions that cause a need in additional component layers. A few examples:
    1. The only data that can be passed has to be valid JSON (primitives), no functions or classes allowed.
      1. This means that the initial context can’t be computed inside a "client component" like SessionAuth
    2. All the component logic has to be in separate files marked with 'use client'; or 'use server'; depending on the components purpose.
    3. This leads to the following structure of nested components (abstraction) required to do what SessionAuth does on both server & client. (see below)
    4. With custom SSR implementation outside NextJS [App router], it could be possible to combine all the logic in a single component, if the same component is rendered exactly the same on both server & client. However with NextJS [App Router] it is not possible, and the following structure is needed to be in place.

Example NextJS Server SessionAuth structure

Restriction: it's only possible to pass JSON data between ServerSessionAuth <> ClientSessionAuth (see above)

Implementation

Example of ServerSessionAuth implementation (NextJS) app/components/serverSessionAuth.tsx:

'use server'; // We mark NextJS that this is a server component

import { PropsWithChildren } from 'react';
import { SessionContainer } from 'supertokens-node/recipe/session';
import { getSSRSession, getInitialSessionAuthContext } from "supertokens-node/nextjs";
import { redirect } from "next/navigation";
import { TryRefreshComponent } from "@/app/components/tryRefreshClientComponent";
import { cookies, headers } from "next/headers";
import { SessionAuth, SessionAuthProps } from 'supertokens-auth-react/recipe/session';

// A helper function to catch error from
// https://supertokens.com/docs/thirdpartyemailpassword/nextjs/app-directory/server-components-requests 
async function getSSRSessionHelper(): Promise<{
    session: SessionContainer | undefined;
    hasToken: boolean;
    hasInvalidClaims: boolean;
    error: Error | undefined;
}> {
    let session: SessionContainer | undefined;
    let hasToken = false;
    let hasInvalidClaims = false;
    let error: Error | undefined = undefined;

    try {
        ({ session, hasToken, hasInvalidClaims } = await getSSRSession(cookies().getAll(), headers()));
    } catch (err: any) {
        error = err;
    }
    return { session, hasToken, hasInvalidClaims, error };
}

// We don't need initialSessionAuthContext as it is computed internally inside ServerSessionAuth
type ServerSessionAuthProps = PropsWithChildren<Pick<SessionAuthProps, 'requireAuth' | 'doRedirection'>>;

export default async function ServerSessionAuth(props: ServerSessionAuthProps) {
  const { session, hasToken, hasInvalidClaims, error } = await getSSRSessionHelper();

  if (error) {
    return <div>Something went wrong while trying to get the session. Error - {error.message}</div>;
  }

  if (props.requireAuth && !session) {
    if (!hasToken) {
      /**
       * This means that the user is not logged in. If you want to display some other UI in this
       * case, you can do so here.
       */
      if (props.doRedirection) {
        return redirect("/auth")
      } else {
        return null
      }
    }

    if (!hasInvalidClaims) {
      return <TryRefreshComponent />;
    }
  }

  // Get initial Session provider context and pass it to the client component for rendering data based on conditions
  const initialSessionAuthContext = await getInitialSessionAuthContext(session)

  return (
    <SessionAuth {...props} initialSessionAuthContext={initialSessionAuthContext}>
      {props.children}
    </SessionAuth>
  );
}

The logic in the above component is adapted for NextJS App Router, and depending on scenario other customisations to the ServerSessionAuth may be needed. Therefor, it is not a component for supertokens-auth-react library, and is rather a customer-side implementation that could be presented in documentation as example.

rishabhpoddar commented 5 months ago

Can you show how users can use one of the function props provided to SessionAuth? For example, overrideGlobalClaims function.

sasha240100 commented 5 months ago

@rishabhpoddar In that case users will have to implement a ClientSessionAuth in the middle between ServerSessionAuth and SessionAuth:

"use client";

import {PropsWithChildren} from 'react';
import {SessionAuth, SessionAuthProps} from 'supertokens-auth-react/recipe/session';

export function ClientSessionAuth(props: PropsWithChildren<SessionAuthProps>) {
  // Custom implementation of overrideGlobalClaimValidators goes here
  const overrideGlobalClaimValidators = () => []

  return (
    <SessionAuth {...props} overrideGlobalClaimValidators={overrideGlobalClaimValidators}>
      {props.children}
    </SessionAuth>
  )
}

There are several things that prevent from having exactly the same function for overrideGlobalClaimValidators on server side & client side:

Types difference:

// supertokens-auth-react
export declare type SessionClaimValidator = {
    readonly id: string;
    /**
     * Makes an API call that will refresh the claim in the token.
     */
    refresh(userContext: any): Promise<void>;
    /**
     * Decides if we need to refresh the claim value before checking the payload with `validate`.
     * E.g.: if the information in the payload is expired, or is not sufficient for this validator.
     */
    shouldRefresh(accessTokenPayload: any, userContext: any): Promise<boolean> | boolean;
    /**
     * Decides if the claim is valid based on the accessTokenPayload object (and not checking DB or anything else)
     */
    validate(accessTokenPayload: any, userContext: any): Promise<ClaimValidationResult> | ClaimValidationResult;
};

// supertokens-node
export declare type SessionClaimValidator = (
    | // We split the type like this to express that either both claim and shouldRefetch is defined or neither.
    {
          claim: SessionClaim<any>;
          /**
           * Decides if we need to refetch the claim value before checking the payload with `isValid`.
           * E.g.: if the information in the payload is expired, or is not sufficient for this check.
           */
          shouldRefetch: (payload: any, userContext: any) => Promise<boolean> | boolean;
      }
    | {}
) & {
    id: string;
    /**
     * Decides if the claim is valid based on the payload (and not checking DB or anything else)
     */
    validate: (payload: any, userContext: any) => Promise<ClaimValidationResult>;
};
rishabhpoddar commented 5 months ago

TODO for later (this is out of the scope of the interview trial): We need to do something about SuperTokensWrapper as well.