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:
- Register page: create an unauthenticated user + send verification email
- Verification page
- Request a new verification email
- Login page
- Request password reset page (forgot password)
- Reset password page
- Change password page
- 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
2 notes here:
- We're creating a frontend user, not a backend user (one that can access our
Strapi admin
). - 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;
},
}),
],
}
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:
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>
);
}
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:
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: '...',
});
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,
});
}
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,
});
}
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,
}),
}
);
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,
};
},
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'
},
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;
},
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;
},
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.
By default the user object in
NextAuth
has some properties: name and email (optional) but also an id (required). That is why in theauthorize
function, I returned an id property:id: data.user.id.toString()
. This is ourStrapi 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.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 theauthorize
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;
}
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'
}
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.