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 ongithub: branch credentialssignin.
Setup
By default, the email provider is enabled inStrapi
. So nothing to do here. We have to create aStrapi user
so we have a user to test our credentials sign in that we will be building. InStrapi 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'},},asyncauthorize(credentials,req){console.log('calling authorize');returnnull;},}),],}
We have name, credentials and authorize properties. The name and credentials properties are actually mostly useless. These serve to populate the defaultsign in page thatNextAuth
generates. Since we use a customsign 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 tohttp://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 theasync 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 customsign in page has the same keys (form names and id's).
Finally, theauthorize
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.tsxexportdefaultfunctionSignInForm(){return(<formmethod='post'className='my-8'><divclassName='mb-3'><labelhtmlFor='identifier'className='block mb-1'> Email or username *</label><inputtype='text'id='identifier'name='identifier'requiredclassName='bg-white border border-zinc-300 w-full rounded-sm p-2'/></div><divclassName='mb-3'><labelhtmlFor='password'className='block mb-1'> Password *</label><inputtype='password'id='password'name='password'requiredclassName='bg-white border border-zinc-300 w-full rounded-sm p-2'/></div><divclassName='mb-3'><buttontype='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 oursign in with Google
button. We have to call ourNextAuth
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 callsignIn
with this id.
At this point, you may be thinking about using a server action to handle our form submit. This not possible becausesignIn
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 initialStateconstinitialState={identifier:'',password:'',};// set stateconst[data,setData]=useState(initialState);// create an event handlerfunctionhandleChange(e:React.ChangeEvent<HTMLInputElement>){setData({...data,[e.target.name]:e.target.value,});}
And finally, we update our inputs withvalue={data.identifier} onChange={handleChange}
. So, basically, we make the inputs controlled inputs. This should be clear.
Next, create an onsubmit handler and call thesignIn
function:
functionhandleSubmit(e:React.FormEvent<HTMLFormElement>){e.preventDefault();signIn('credentials',{identifier:data.identifier,password:data.password,});}
At this point, our request leaves our form component and theauthorize
andcallback
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
Theauthorize
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 askStrapi
if these data are correct via an api call.
On success,Strapi
will return the user data and aStrapi JWT token
. We will then put this token into theNextAuth JWT token
using theNextAuth
callbacks. When our api call toStrapi
returns an error (f.e. incorrect password), we will have to handle this error somehow.
It's best to look atauthorize
as another of theNextAuth
callback functions because it actually is a callback function. The return value fromauthorize
is passed into the other callbacks as the user argument. The user argument in thejwt callback
is the return value from theauthorize
function.
This makes sense. When using the GoogleProvider earlier, Google OAuth sends back data. This data is then used byNextAuth
to populate account, profile and user. When using the CredentialsProvider there is no such data. You need to fetch this data yourself fromStrapi
.
Authorize
has 2 parameters: credentials (identifier and password) and the actual request object. We will use these credentials and send them toStrapi
.
Strapi API
TheStrapi
api endpoint that we need is/api/auth/local
. We need to make a POST request and send the credentials along as JSON:
conststrapiResponse=awaitfetch(`${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:
asyncauthorize(credentials,req){conststrapiResponse=awaitfetch(`${process.env.STRAPI_BACKEND_URL}/api/auth/local`,{method:'POST',headers:{'Content-type':'application/json',},body:JSON.stringify({identifier:credentials!.identifier,password:credentials!.password,}),});constdata:StrapiLoginResponseT=awaitstrapiResponse.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 ourNextAuth
flow, once we return data fromauthorize
, the callbacks get called. ThesignIn
callback will just return true and is not relevant. Thejwt callback
will be called next.
Customizing the NextAuth jwt callback for the CredentialsProvider
From working with GoogleProvider, we learned that when we sign in, thejwt 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 fromauthorize
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 thejwt callback
. Why? When account is defined andaccount.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 updatedjwt callback
:
asyncjwt({token,trigger,account,user,session}){if(account){if(account.provider==='google'){// we now know we are doing a sign in using GoogleProvidertry{conststrapiResponse=awaitfetch(`${process.env.STRAPI_BACKEND_URL}/api/auth/${account.provider}/callback?access_token=${account.access_token}`,{cache:'no-cache'});if(!strapiResponse.ok){conststrapiError:StrapiErrorT=awaitstrapiResponse.json();thrownewError(strapiError.error.message);}conststrapiLoginResponse:StrapiLoginResponseT=awaitstrapiResponse.json();// customize token// name and email will already be on heretoken.strapiToken=strapiLoginResponse.jwt;token.strapiUserId=strapiLoginResponse.user.id;token.provider=account.provider;token.blocked=strapiLoginResponse.user.blocked;}catch(error){throwerror;}}if(account.provider==='credentials'){// for credentials, not google provider// name and email are taken care of by next-auth or authorizetoken.strapiToken=user.strapiToken;token.strapiUserId=user.strapiUserId;token.provider=account.provider;token.blocked=user.blocked;}}returntoken;},
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. Whenaccount.provider === 'google'
, we make an api call toStrapi
inside thejwt callback
. We then use the strapiResponse to populate the token. Whenaccount.provider === 'credentials'
, we already made the api call toStrapi
inside theauthorize
function. This data then gets send along to thejwt callback
via the user object. We then use this user object to populate the token. That is why the credentials part of thejwt callback
is so simple.
Customizing the NextAuth session callback for the CredentialsProvider
As you can see above, the token object returned by thejwt callback
has the same properties for both google as credentials provider. We carefully and intentionally made it so. This means that thesession callback
does not need to be updated.
asyncsession({token,session}){session.strapiToken=token.strapiToken;session.provider=token.provider;session.user.strapiUserId=token.strapiUserId;session.user.blocked=token.blocked;returnsession;},
Shapes and Types
I just stated that I carefully and intentionally shaped all the callback arguments and theauthorize
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 alreadystruggled 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.tsinterfaceUserextendsDefaultSession['user']{// not setting this will throw ts error in authorize functionstrapiUserId?: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 thesignIn
function and passed the credentials. This leads to theauthorize
function that is defined in the CredentialsProvider.
Authorize
is an integrated part of the callbacks inNextAuth
when using credentials. Insideauthorize
, we retrieve our user data fromStrapi
and then return these (edited) data. This return value equals the user argument in our callbacks. To finalize the flow, we updated ourjwt callback
.
Our app as it stands right now, will work with credentials. When we run it, sign in with credentials and loguseSession
orgetServerSession
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 theauthorize
function we have no error handling for theStrapi
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 candonate with paypal.
Top comments(0)
For further actions, you may consider blocking this person and/orreporting abuse