DEV Community

Peter Jacxsens
Peter Jacxsens

Posted on • Updated on

10/ NextAuth CredentialsProvider: signing in

We will now add the credentials auth flow to our project. By credentials we mean the old school login method of email + password. NextAuth calls this the credentials provider. Strapi calls this local auth. Here is an overview of what we need:

  1. Register page: create an unauthenticated user + send verification email
  2. Verification page
  3. Request a new verification email
  4. Login page
  5. Request password reset page (forgot password)
  6. Reset password page
  7. Change password page
  8. Update user (me) page

In this chapter we are going to add credentials to our custom sign in page.

All the code for this chapter is available on github: branch credentialssignin.

Setup

By default, the email provider is enabled in Strapi. So nothing to do here. We have to create a Strapi user so we have a user to test our credentials sign in that we will be building. In Strapi admin:

Content Manager > Collection Types > User > Create a new entry
Enter fullscreen mode Exit fullscreen mode

2 notes here:

  1. We're creating a frontend user, not a backend user (one that can access our Strapi admin).
  2. If you're coding along, make sure to use an email that you own because we will be sending emails to this address later on.

Create a user:

  • username: Bob
  • email: bob@example.com
  • password: 123456
  • confirmed: true
  • blocked: false
  • role: authenticated

Save and we're done.

Register our credentials provider with NextAuth

In our authOptions.providers we already have the GoogleProvider. We now import and add CredentialsProvider to the providers array. This is our starting setup:

// frontend/src/app/api/auth/[...nextauth]/authOptions.ts
{
  providers: [
    //...
    CredentialsProvider({
      name: 'email and password',
      credentials: {
        identifier: {
          label: 'Email or username *',
          type: 'text',
        },
        password: { label: 'Password *', type: 'password' },
      },
      async authorize(credentials, req) {
        console.log('calling authorize');
        return null;
      },
    }),
  ],
}
Enter fullscreen mode Exit fullscreen mode

We have name, credentials and authorize properties. The name and credentials properties are actually mostly useless. These serve to populate the default sign in page that NextAuth generates. Since we use a custom sign in page, things like name or the labels aren't used.

Let's quickly revert back to this default sign in page and see what we get. In authOptions.pages, comment out signin, start up the app and navigate to http://localhost:3000/api/auth/signin:

NextAuth default signin credentials

And we have everything we would expect. The Google sign in and the credentials sign in with email/username and password + a submit button. Note: strapi lets you sign in with either the email or username. We account for this by using an identifier field that can be a username or email. We won't use the default sign in page but it makes it clear what the credentials settings are for. But, there is more.

NextAuth also using these settings to infer Types. In the async authorize(credentials, req) {} function that we added, the credentials argument is of Type CredentialsProvider.credentials, so { identifier: string, password: string }. This means that we have to make sure that our custom sign in page has the same keys (form names and id's).

Finally, the authorize function is where we will handle the submitted form, but more on that later. Let's revert the authOptions.pages.signin back to our custom page and move on.

Creating a sign in form

We need a form, this is our next step. Create a new component <SignInForm />:

// frontend/src/components/auth/signin/SignInForm.tsx

export default function SignInForm() {
  return (
    <form method='post' className='my-8'>
      <div className='mb-3'>
        <label htmlFor='identifier' className='block mb-1'>
          Email or username *
        </label>
        <input
          type='text'
          id='identifier'
          name='identifier'
          required
          className='bg-white border border-zinc-300 w-full rounded-sm p-2'
        />
      </div>
      <div className='mb-3'>
        <label htmlFor='password' className='block mb-1'>
          Password *
        </label>
        <input
          type='password'
          id='password'
          name='password'
          required
          className='bg-white border border-zinc-300 w-full rounded-sm p-2'
        />
      </div>
      <div className='mb-3'>
        <button
          type='submit'
          className='bg-blue-400 px-4 py-2 rounded-md disabled:bg-sky-200 disabled:text-gray-500'
        >
          sign in
        </button>
      </div>
    </form>
  );
}
Enter fullscreen mode Exit fullscreen mode

We just added 2 inputs (identifier and password) and a button. We then load this form into our <SignIn /> component and it looks like this:

NextAuth sign in form with Credentials

Calling NextAuth signIn function

We know what to do next because we did the same with our sign in with Google button. We have to call our NextAuth signIn function with some arguments:

signIn('credentials', {
  identifier: '...',
  password: '...',
});
Enter fullscreen mode Exit fullscreen mode

Quick note here. It is possible to have multiple credential providers. In this case you would add an id property to each CredentialProvider and call signIn with this id.

At this point, you may be thinking about using a server action to handle our form submit. This not possible because signIn is a client side function. You cannot call if from the server side. Therefor, we must also put our inputs into state. We update our component:

// add initialState
const initialState = {
  identifier: '',
  password: '',
};

// set state
const [data, setData] = useState(initialState);

// create an event handler
function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
  setData({
    ...data,
    [e.target.name]: e.target.value,
  });
}
Enter fullscreen mode Exit fullscreen mode

And finally, we update our inputs with value={data.identifier} onChange={handleChange}. So, basically, we make the inputs controlled inputs. This should be clear.

Next, create an onsubmit handler and call the signIn function:

function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
  e.preventDefault();
  signIn('credentials', {
    identifier: data.identifier,
    password: data.password,
  });
}
Enter fullscreen mode Exit fullscreen mode

At this point, our request leaves our form component and the authorize and callback functions come into play. We're not done with this form yet. We need error handling, input validation, loading state, ... But we will come back to that later.

Writing the NextAuth Authorize function for the CredentialsProvider

The authorize function that we created inside our CredentialProvider earlier is the main workhorse of our credential auth flow. Let's think about where we are right now. The user submitted an identifier (email/username) and a password. What do we need to do with these? We have to ask Strapi if these data are correct via an api call.

On success, Strapi will return the user data and a Strapi JWT token. We will then put this token into the NextAuth JWT token using the NextAuth callbacks. When our api call to Strapi returns an error (f.e. incorrect password), we will have to handle this error somehow.

It's best to look at authorize as another of the NextAuth callback functions because it actually is a callback function. The return value from authorize is passed into the other callbacks as the user argument. The user argument in the jwt callback is the return value from the authorize function.

This makes sense. When using the GoogleProvider earlier, Google OAuth sends back data. This data is then used by NextAuth to populate account, profile and user. When using the CredentialsProvider there is no such data. You need to fetch this data yourself from Strapi.

Authorize has 2 parameters: credentials (identifier and password) and the actual request object. We will use these credentials and send them to Strapi.

Strapi API

The Strapi api endpoint that we need is /api/auth/local. We need to make a POST request and send the credentials along as JSON:

const strapiResponse = await fetch(
  `${process.env.STRAPI_BACKEND_URL}/api/auth/local`,
  {
    method: 'POST',
    headers: {
      'Content-type': 'application/json',
    },
    body: JSON.stringify({
      identifier: credentials.identifier,
      password: credentials.password,
    }),
  }
);
Enter fullscreen mode Exit fullscreen mode

From this strapiResponse, we can then return the user data:

async authorize(credentials, req) {
  const strapiResponse = await fetch(
    `${process.env.STRAPI_BACKEND_URL}/api/auth/local`,
    {
      method: 'POST',
      headers: {
        'Content-type': 'application/json',
      },
      body: JSON.stringify({
        identifier: credentials!.identifier,
        password: credentials!.password,
      }),
    }
  );
  const data: StrapiLoginResponseT = await strapiResponse.json();
  return {
    name: data.user.username,
    email: data.user.email,
    id: data.user.id.toString(),
    strapiUserId: data.user.id,
    blocked: data.user.blocked,
    strapiToken: data.jwt,
  };
},
Enter fullscreen mode Exit fullscreen mode

Inside our NextAuth flow, once we return data from authorize, the callbacks get called. The signIn callback will just return true and is not relevant. The jwt callback will be called next.

Customizing the NextAuth jwt callback for the CredentialsProvider

From working with GoogleProvider, we learned that when we sign in, the jwt callback arguments token, trigger, account, user and session will all be populated. When the user is already signed in, all these (except token) will be undefined.

When signing in with the CredentialsProvider we get something similar. Token, trigger, account and user will be populated. User is what we just returned from authorize and account is this:

account: {
  providerAccountId: undefined,
  type: 'credentials',
  provider: 'credentials'
},
Enter fullscreen mode Exit fullscreen mode

Again, similarly to what we did using the GoogleProvider we listen for account.provider inside the jwt callback. Why? When account is defined and account.provider === 'credentials', we know that a user just signed in with credentials and we need to update the token with this data. This is our updated jwt callback:

async jwt({ token, trigger, account, user, session }) {
  if (account) {
    if (account.provider === 'google') {
      // we now know we are doing a sign in using GoogleProvider
      try {
        const strapiResponse = await fetch(
          `${process.env.STRAPI_BACKEND_URL}/api/auth/${account.provider}/callback?access_token=${account.access_token}`,
          { cache: 'no-cache' }
        );
        if (!strapiResponse.ok) {
          const strapiError: StrapiErrorT = await strapiResponse.json();
          throw new Error(strapiError.error.message);
        }
        const strapiLoginResponse: StrapiLoginResponseT =
          await strapiResponse.json();
        // customize token
        // name and email will already be on here
        token.strapiToken = strapiLoginResponse.jwt;
        token.strapiUserId = strapiLoginResponse.user.id;
        token.provider = account.provider;
        token.blocked = strapiLoginResponse.user.blocked;
      } catch (error) {
        throw error;
      }
    }
    if (account.provider === 'credentials') {
      // for credentials, not google provider
      // name and email are taken care of by next-auth or authorize
      token.strapiToken = user.strapiToken;
      token.strapiUserId = user.strapiUserId;
      token.provider = account.provider;
      token.blocked = user.blocked;
    }
  }
  return token;
},
Enter fullscreen mode Exit fullscreen mode

By default, NextAuth will populate token with name and email properties. We then manually set the other properties.

Try to feel the auth flow here. When account.provider === 'google', we make an api call to Strapi inside the jwt callback. We then use the strapiResponse to populate the token. When account.provider === 'credentials', we already made the api call to Strapi inside the authorize function. This data then gets send along to the jwt callback via the user object. We then use this user object to populate the token. That is why the credentials part of the jwt callback is so simple.

Customizing the NextAuth session callback for the CredentialsProvider

As you can see above, the token object returned by the jwt callback has the same properties for both google as credentials provider. We carefully and intentionally made it so. This means that the session callback does not need to be updated.

async session({ token, session }) {
  session.strapiToken = token.strapiToken;
  session.provider = token.provider;
  session.user.strapiUserId = token.strapiUserId;
  session.user.blocked = token.blocked;
  return session;
},
Enter fullscreen mode Exit fullscreen mode

Shapes and Types

I just stated that I carefully and intentionally shaped all the callback arguments and the authorize function. This is a messy process. The baseline is that we mirror all these arguments between our credentials and google provider.

When we were setting up our GoogleProvider we already struggled with setting up types. Adding our CredentialsProvider made everything a bit more complex. Here is a couple of things I had to do.

  1. By default the user object in NextAuth has some properties: name and email (optional) but also an id (required). That is why in the authorize function, I returned an id property: id: data.user.id.toString(). This is our Strapi user id (number). NextAuth user id is a string so we converted it. We don't actually use this id but it does throw a TypeScript error if we don't add an id property. This was my solution. As I said, messy.

  2. A second problem I encountered was with the user Type. When handling the GoogleProvider inside the jwt callback, we grab strapiToken and strapiUserId from the strapiResponse. But, when using the CredentialsProvider, we make this api call in the authorize function and return the data from this function as the user object. This means that we have to use our user object to pass the strapiToken and strapiUserId. To keep TypeScript happy, we have to update our user Type:

// frontend/src/types/nextauth/next-auth.d.ts

interface User extends DefaultSession['user'] {
  // not setting this will throw ts error in authorize function
  strapiUserId?: number;
  strapiToken?: string;
  blocked?: boolean;
}
Enter fullscreen mode Exit fullscreen mode

This means that now, both our User and JWT interfaces have an optional strapiUserId and strapiToken property. There is no way around this (TypeScript keeps yelling) and it is messy. Don't worry if you don't fully understand this. Once you start coding this yourself, it will make sense.

Summary

We started by coding a form with controlled input fields. Onsubmit we called the signIn function and passed the credentials. This leads to the authorize function that is defined in the CredentialsProvider.

Authorize is an integrated part of the callbacks in NextAuth when using credentials. Inside authorize, we retrieve our user data from Strapi and then return these (edited) data. This return value equals the user argument in our callbacks. To finalize the flow, we updated our jwt callback.

Our app as it stands right now, will work with credentials. When we run it, sign in with credentials and log useSession or getServerSession we get what we expect:

{
  user: {
    name: 'Bob',
    email: 'bob@example.com',
    image: undefined,
    strapiUserId: 2,
    blocked: false
  },
  strapiToken: 'longtokenhere',
  provider: 'credentials'
}
Enter fullscreen mode Exit fullscreen mode

But, we skipped over a lot of things. In the authorize function we have no error handling for the Strapi api call. On top of that, our client-side code (the form) is also unfinished: we need error and success handling, input validation and loading states. We will deal with this in the next chapter.


If you want to support my writing, you can donate with paypal.

Top comments (0)