Skip to content

Latest commit

 

History

History
673 lines (513 loc) · 20.6 KB

server-side-auth-next-api-routes.md

File metadata and controls

673 lines (513 loc) · 20.6 KB

Recipe: Server-side Authentication flow with Next.JS API Routes.

This recipe explains how to authenticate users server-side, using one of Headless Login for WPGraphQL's built-in authentication providers. We'll show examples for OAuth2 and Password authentication.

We'll be using iron-session to store the user's session data, but you can use any session management library you like.

For a more complete example, see the AxePress Playground example branch.

Table of Contents

1. Configure the Headless Login providers

For more information on configuring the providers, see the Settings Guide.

Note: OAuth2 providers require a 'Redirect URI', which we will be creating as a Next.js API route in Step 3.

You can create a different API route for each Login Client, or use a catch-all route to handle all of them (what we'll be doing in this example).

2. Create the Login component

In your headless app, you will need to create a Login component that sends the user to authenticate with the provider.

2A. OAuth2 authentication

OAuth2 providers require a Redirect URI to be configured. This is the URL that the provider will send the authentication response data to.

You can choose to create the necessary authorizationUrl yourself, or use the one generated by the plugin for a DRYer solution.

E.g.

// Login.js

// replace fetchAPI with whatever you're using to connect to WPGraphQL.
const data = await fetchAPI(
  `query LoginClients {
    loginClients {
      authorizationUrl
      provider
      name
      isEnabled
      ...OtherLoginClientFields
    }
  }`
);

// Filter out the disabled clients.
const enabledClients = data?.login?.filter( ( client ) => client?.isEnabled ) || [];

// Get the Oauth2 Clients.
const oauthClients = enabledClients.filter( ( client ) => client?.authorizationUrl );

return (
  <>
    {
      oauthClients?.length && oauthClients.map(
        ( client ) => (
          <a key={client.provider} href={client.authorizationUrl}>
            Login with ${client.name}
          </a>
        )
      )
    }
  </>
);

When a user clicks the link, they will be directed to the Authentication provider. Once they authenticate, the Provider will send the authentication response data to the Callback Redirect URI, which will be sent to WPGraphQL by our Authentication API route.

2B. Password authentication.

For Password authentication, we need to create a LoginForm component that sends the user's credentials to our Authentication API route.

// LoginForm.js

const [ usernameEmail, setUsernameEmail ] = useState( '' );
const [ password, setPassword ] = useState('');

const { login, isLoading, errors } = usePasswordLogin(); // We'll define this hook later.

return (
  <form
    onSubmit={ ( e ) => {
      e.preventDefault();

      // We'll define this later, but for now its enough to know it takes the username, password, and redirect URL and processes it via our Authentication API route.
      login( usernameEmail, password, '/dashboard' );
    } }
  >
    <input
      type="text"
      name="username"
      placeholder="Username"
      value={usernameEmail}
      onChange={( e ) => setUsernameEmail( e.target.value )}
    />
    <input
      type="password"
      name="password"
      placeholder="Password"
      value={password}
      onChange={( e ) => setPassword( e.target.value )}
    />
    <button type="submit">Login</button>
  </form>
);

3. Create the Authentication API route

The Authentication API route is where we process the authentication data and send it to WPGraphQL. We're using an API route to prevent the authentication data from being exposed to the client, but you can also use middleware or a serverless function to do this.

To keep our code DRY, we'll breaking our code into a few reusable parts, that we'll then use in the provider-specific API routes.

3A. The authenticate function

This function takes the authentication data from the provider, and sends it to WPGraphQL. It returns the user's authentication data, which we'll use to create the user's session.

// lib/auth/authenticate.js

async function authenticate( variables ) {
  const query = `
    mutation Login($input: LoginInput!) {
      login(input: $input) {
        authToken
        refreshToken
        userData: user { # We're renaming this to make our JS code more readable.
          ...UserFields
        }
      }
    }
  `;

  // replace fetchAPI with whatever you're using to connect to WPGraphQL.
  const res = await fetchAPI( query, { variables } );

  if ( res?.errors ) {
    throw new Error( res.errors[0].message );
  }

  return res?.data?.login;
}

3B. The loginHandler function

This function takes the authentication data from the provider, and creates the user's session.

// lib/auth/loginHandler.js

export async function loginHandler( req, res, input ) {
  try {
    const data = await authenticate( input );

    // We're using iron session to store the session data in a secure httpOnly cookie, but you can use any session management library you like.
    const user = {
      ...data,
      isLoggedIn: true,
    };

    req.session.user = user;
    await req.session.save();

    // Let's send them somewhere.
    return res.redirect( 307, '/dashboard' );
  } catch ( e ) {
    // Do something with the error
    res.status( 401 ).json( { error: e.message } );

    // Or redirect them to the login page.
    return res.redirect( 401, '/login' );
  }
}

// And some more iron-session stuff:

//config/ironOptions.js
export const ironOptions = {
  cookieName: 'wp-graphql-headless-login-session',
  password: process.env.SECRET_COOKIE_PASSWORD,
  cookieOptions: {
    // the next line allows to use the session in non-https environments like
    // Next.js dev mode (http://localhost:3000)
    secure: process.env.NODE_ENV === 'production',
  },
};

3C. The provider-specific API routes

Now that we have our authenticate and loginHandler functions, we can create our provider-specific API routes.

For this example, we're going to use a Catch-All route (e.g. /pages/api/auth/[provider].js ), but you can also use a separate route for individual providers that have differing logic (e.g. password authentication).

// pages/api/auth/[provider].js
import { withIronSessionApiRoute } from 'iron-session/next';
import { loginHandler } from '@/lib/auth/loginHandler'; // What we created in step 3B.
import { ironOptions } from '@/config/ironOptions'; // What we created in step 3B.

// A simple helper function to get the provider-specific input for the mutation.
async function getProviderInput( provider, req ) {
  const providerEnum = provider.toUpperCase()

  switch ( providerEnum ) {
    case 'PASSWORD':
      return {
        provider: providerEnum,
        credentials: {
          username: req.body.username,
          password: req.body.password,
        },
      };
    // All OAuth2 Provider share the same input shape.
    default:
      const input = {
        provider: providerEnum,
        oauthResponse: {
          code: req.query.code,
        },
      }

      if ( req.query?.state ) { // Not all providers send a state.
        input.oauthResponse.state = req.query.state;
      }

      return input;
  }
}

async function handler( req, res ) {
  const provider = req.query?provider || '';

  const input = await getProviderInput( provider, req );

  return loginHandler( req, res, input );
}

// This is an iron-session thing.
export default withIronSessionApiRoute( loginHandler, ironOptions );

4. Create the Logout API route

On your Logout API route (e.g. /pages/api/logout.js ), you can clear the session data.

Since we're using iron-session, we can just call req.session.destroy(). If you are useing a different session management library or your own secure cookie implementation, you'll need to use that library's API to clear the session data.

// pages/api/auth/logout.js
import { withIronSessionApiRoute } from 'iron-session/next';
import { ironOptions } from '@/config/ironOptions'; // What we created in step 3B.

async function logoutHandler( req, res ) {
  req.session.destroy();

  // Let's send back some JSON.
  return res.status( 200 ).json( { isLoggedIn: false } );
}

export default withIronSessionApiRoute( logoutHandler, ironOptions );

5. Create the Token Validation API route

Headless Login uses JWT tokens for authentication. These tokens have an expiration time, and you will need to refresh them before they expire.

We can handle validating and refreshing the token on the server-side, so we don't expose these tokens to the client.

We'll use jsonwebtoken to decode the token.

Note: req.session is made available by iron-session. If you're using a different session management library, you'll need to use that library's API to access the session data.

// pages/api/auth/user.js
import { JwtPayload, decode } from 'jsonwebtoken';
import { withIronSessionApiRoute } from 'iron-session/next';
import { ironOptions } from '@/config/ironOptions'; // What we created in step 3B.

// We'll use this function in our handler, to check if the authToken has expired.
function isTokenExpired( token ) : boolean {
  const decodedToken = decode( token );

  if ( ! decodedToken?.exp ) {
    return false;
  }

  // Expiry time is in seconds, but we need milliseconds so we do *1000
  const expiresAt = new Date( ( decodedToken.exp ) * 1000 );
  const now = new Date();

  return now.getTime() > expiresAt.getTime();;
}

// Our refresh token call to WPGraphQL.
async function refreshAuthToken( refreshToken ) {
  const query = `
    mutation RefreshAuthToken( $refreshToken: String! ) {
      refreshToken(
        input: {refreshToken: $refreshToken }
      ) {
        authToken
      }
    }
  `;

  const variables = {
    refreshToken,
  };

  // replace fetchAPI with whatever you're using to connect to WPGraphQL.
  const res = await fetchAPI( query, { variables } );

  if ( res?.errors ) {
    throw new Error( res?.errors[ 0 ].message );
  }

  return res?.data?.refreshToken;
}

async function userHandler( req, res ) {
  const user = req.session?.user;

  // If the user doesn't have a refrsh token, they're not logged in.
  if ( ! user?.refreshToken ) {
    req.session.user = {
      ...user,
      isLoggedIn: false,
    };

    await req.session.save();

    return res.status( 401 ).json( {
      error: 'User is not logged in.',
      user: user?.userData,
      isLoggedIn: user?.isLoggedIn,
    } );
  }

  // If the user is missing an auth token or it is expired, try to refresh it.
  if ( ! user?.authToken || isTokenExpired( user.authToken ) ) {
    try {
      const { authToken, refreshToken, success } = await refreshTokens(
        user.refreshToken
      );

      // If the auth token is empty, log the user out.
      if ( ! authToken ) {
        req.session.destroy();

        return res.status( 401 ).json( {
          error: 'User is not logged in.',
          user: undefined,
          isLoggedIn: false,
        } );
      }

      user.authToken = authToken;
      user.isLoggedIn = true;

      // update the user session.
      req.session.user = user;

      await req.session.save();

      return res.status( 200 ).json( {
        user: user?.userData,
        isLoggedIn: user.isLoggedIn,
      } );
    } catch {
      // This means the mutation failed, so the user is not logged in.
      // We don't destroy the session here, because we want to keep the stale data in case the server fixes itself.
      user.isLoggedIn = false;

      req.session.user = user;

      await req.session.save();

      return res.status( 401 ).json( {
        error: 'User is not logged in.',
        user: user?.userData,
        isLoggedIn: user.isLoggedIn,
      } );
    }
  }

  // If we get here, the user is logged in.
  return res.status( 200 ).send( user );
}

export default withIronSessionApiRoute( userHandler, ironOptions );

6. Use the authToken in your GraphQL requests

Now that we have a way to authenticate with WPGraphQL, we can use the authToken in our GraphQL requests.

You can do this by fetching the authToken from the session data, and passing it in the Authorization header.

For example: here's the fetchAPI function we've been using until now.

// utils/fetchAPI.js

export default async function fetchAPI( query, { variables } = {} ) {
	// Get the current user from the session data.
  const currentUser = await fetch('/api/auth/user').then( res => res.json() );

  const headers = { 'Content-Type': 'application/json' };

  if( currentUser?.authToken ) {
    headers.Authorization = `Bearer ${currentUser.authToken}`;
  }

  try {
    const res = await fetch( process.env.WPGRAPHQL_URL, { // This is the URL to your GQL endpoint.
      method: 'POST',
      headers,
      body: JSON.stringify( {
        query,
        variables,
      } ),
    } );

    const json = await res.json();

    if ( json.errors ) {
      console.error( json.errors );
      throw new Error( 'Failed to fetch API' );
    }

    return json.data;

  } catch ( e ) {
    return {
      errors: [ e ],
    }
  }
}

The same approach can be taken with Apollo Client, or any other GraphQL client.

7. (Optional) Create some custom hooks

We can create custom hooks to make it easier to handle authentication flows.

Here are a few common examples.

Note: We are using the fetch API but you can use 'swr', or any other library you prefer.

useAuth

This example hook will return whether the user is authenticated, and possibly redirect them to a specific page.

// hooks/useAuth.js
import { useEffect, useState } from 'react';

export function useAuth( {
  redirectTo = false, // An optional URL to redirect to.
  redirectOnError = false, // If true, redirect if the user is already logged in.
} ) {
  const [ isLoading, setIsLoading ] = useState( true );
  const [ userData, setUserData ] = useState( undefined );
  const [ error, setError ] = useState();
  const [ isAuthenticated, setIsAuthenticated ] = useState();

  useEffect( () => {
    ( async () => {
      const res = await fetch( '/api/auth/user', {
        method: 'GET',
        headers: {
          'Content-Type': 'application/json',
        },
      } );

      const data = await res.json();

      setIsLoading( false );
      setUserData( data?.user );
      setIsAuthenticated( !! data?.user?.isLoggedIn );
      setError( data?.error );
    } )();
  }, [] );

  useEffect( () => {
    if ( !! isLoading || ! redirectTo || isAuthenticated === undefined ) {
      return;
    }

    if ( redirectOnError !== isAuthenticated ) {
      setTimeout( () => {
        window.location.assign( redirectTo );
      }, 200 );
    }
  }, [ isLoading, isAuthenticated, redirectOnError, redirectTo ] );

  return {
    isLoading,
    isAuthenticated,
    userData,
    error,
  };
}

useLogout

This example hook will log the user out, and optionally redirect them to a specific page upon successful logout.

// hooks/useLogout.js

export function useLogout() {
  const [ error, setError ] = useState( undefined );
  const [ loading, setLoading ] = useState( false );

  async function logout( redirectUrl: string ) {
    setLoading( true );

    const logoutUrl = `/api/auth/logout`;

    const res = await fetch( logoutUrl, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
      },
    } );

    if ( ! res.ok ) {
      setError( res );
      setLoading( false );
      return;
    }

    if ( redirectUrl ) {
      window.location.assign( redirectUrl );
    } else {
      window.location.reload();
    }
  } catch ( e ) {
    setError( e );
  } finally {
    setLoading( false );
  }

  return {
    error,
    logout,
    loading,
  };
}

usePasswordLogin

This example hook will log the user in with a username and password, and optionally redirect them to a specific page upon successful login.

This is the hook we used in our example Login Form component above.

// hooks/usePasswordLogin.js
import { useState } from 'react';

export function usePasswordLogin() {
  const [ loginErrors, setLoginErrors ] = useState();
  const [ isLoading, setIsLoading ] = useState( false );
  const [ userData, setUserData ] = useState( undefined );

  /**
   * A function to log the user in.
   * @param {string} username The username to log in with.
   * @param {string} password The password to log in with.
   * @param {string} redirectTo An optional URL to redirect to after login.
   */
  async function login( username, password, redirectTo ) {
   setIsLoading( true );

    const loginUrl = '/api/auth/login/password';

    const res = await fetch( loginUrl, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
      },
      body: JSON.stringify( {
        username,
        password,
      } ),
    } );
    const data = await res.json();

    if ( ! res.ok ) {
      setLoginErrors( data );
      setIsLoading( false );
      return;
    }

    setUserData( user );
    setIsLoading( false );

    // If we get here, the login was successful, so let's redirect.
    if ( loginRedirectURL ) {
      window.location.assign( loginRedirectUrl );
    }

  }

  return {
    login,
    errors: loginErrors,
    isLoading,
    isAuthenticated: !! userData,
    userData,
  };
}

8. (Optional) Add Support for WPGraphQL for WooCommerce

If you're using WPGraphQL for WooCommerce, you can add support for the customer's Session Token to handle things like guest checkout.

When that plugin is enabled, a woocommerce-session header is added to every GraphQL response where an existing session header isn't provided. To reuse the same session token for future requests, we can just grab it from the response in our Session Handler function and store it in the user's session, and then add it to future requests.

We can then update our fetch requests to include the session token header.

// utils/fetchAPI.js

export default async function fetchAPI( query, { variables } = {} ) {
  const currentUser = await fetch('/api/auth/user').then( res => res.json() );

  const headers = { 'Content-Type': 'application/json' };

  if ( currentUser?.authToken ) {
    headers.Authorization = `Bearer ${currentUser.authToken}`;
  }

  /**
   * This is the code we're adding. It adds the session token if it exists.
   */
  if ( currentUser?.wooSessionToken ) {
    headers['woocommerce-session']: `Session ${currentUser.wooSessionToken}`;
  }

  try ...// The rest of the function.
}

Note: You can also get a new wooSessionToken from the login mutation payload when the user logs in. However, this will be a new session token, and will not be associated with the user's existing session. This means that any items in the user's cart will not be transferred to the new session. If you want to transfer the user's cart to the new session, it's best to rely solely on the woocommerce-session header, and forget about the LoginPayload.wooSessionToken GraphQL field altogether.