Have moved my initial v2 implementation to v3. It was very easy! Everything works as far as I can tell except for the state parameter.
Describe the bug
Using custom provider Azure AD B2C next-auth gives an error (see below) in the callback after successful authentication with Azure AD B2C. This seems to happen whether or not I set useState: false.
This is a new after moving to v3. The same config had worked in v2.
B2C supports the state parameter. I don't _think_ this is an issue with the authorization server, but I could be mistaken. From the B2C link, state is supported as:
A value included in the request that's also returned in the token response. It can be a string of any content that you want. A randomly generated unique value is typically used for preventing cross-site request forgery attacks. The state is also used to encode information about the user's state in the application before the authentication request occurred, such as the page they were on.
I've confirmed (see below) that the state provided by the authorization request is the same as the state returned from the authorization server.
To Reproduce
If this is not something obvious I might have missed please let me know, and I will set up a minimal reproduction.
Expected behavior
I would expect that if the state provided initially by the client & sent back by the authorization server are the same than I should not get an error.
Screenshots or error logs
The B2C Custom Provider looks like:
{
id: 'azureb2c',
name: 'Azure B2C',
type: 'oauth',
version: '2.0',
debug: true,
scope: 'offline_access openid',
// params: {
// grant_type: 'authorization_code',
// },
accessTokenUrl: `https://${tenantName}.b2clogin.com/${tenantName}.onmicrosoft.com/${userFlow}/oauth2/v2.0/token`,
// requestTokenUrl: 'https://login.microsoftonline.com/${process.env.DIRECTORY_ID}/oauth2/v2.0/token',
authorizationUrl: `https://${tenantName}.b2clogin.com/${tenantName}.onmicrosoft.com/${userFlow}/oauth2/v2.0/authorize?response_type=code+id_token&response_mode=form_post`,
profileUrl: 'https://graph.microsoft.com/oidc/userinfo',
profile: (profile) => {
console.log('THE PROFILE', profile)
return {
id: profile.oid,
fName: profile.given_name,
lName: profile.surname,
email: profile.emails.length ? profile.emails[0] : null,
}
},
clientId: process.env.AUTH_CLIENT_ID,
clientSecret: process.env.AUTH_CLIENT_SECRET,
idToken: true,
// useState: false,
},
The B2C authorize url looks like:
https://{tenant}.b2clogin.com/{tenant}.onmicrosoft.com/{flow}/oauth2/v2.0/authorize
?response_type=code+id_token
&response_mode=form_post
&redirect_uri=http%3A%2F%2Flocalhost%3A3000%2Fapi%2Fauth%2Fcallback%2Fazureb2c
&scope=offline_access%20openid
&state=45e76b516360e8aa4e79d44661344ba06f8ed0fc8a08beb3362bcbe7cde2fe90
&client_id=<client_id>
The Form Data response includes (along with the code & id_token):
state: 45e76b516360e8aa4e79d44661344ba06f8ed0fc8a08beb3362bcbe7cde2fe90
The next-auth error looks like:
[next-auth][error][callback_oauth_error] Error: Invalid state returned from oAuth provider
at <irrelevant-project-path>/node_modules/next-auth/dist/server/lib/oauth/callback.js:46:27
at Generator.next (<anonymous>)
at asyncGeneratorStep (<irrelevant-project-path>/node_modules/next-auth/dist/server/lib/oauth/callback.js:26:103)
at _next (<irrelevant-project-path>/node_modules/next-auth/dist/server/lib/oauth/callback.js:28:194)
at <irrelevant-project-path>/node_modules/next-auth/dist/server/lib/oauth/callback.js:28:364
at new Promise (<anonymous>)
at <irrelevant-project-path>/node_modules/next-auth/dist/server/lib/oauth/callback.js:28:97
at <irrelevant-project-path>/node_modules/next-auth/dist/server/lib/oauth/callback.js:143:17
at <irrelevant-project-path>/node_modules/next-auth/dist/server/routes/callback.js:58:31
at Generator.next (<anonymous>)
at asyncGeneratorStep (<irrelevant-project-path>/node_modules/next-auth/dist/server/routes/callback.js:26:103)
at _next (<irrelevant-project-path>/node_modules/next-auth/dist/server/routes/callback.js:28:194)
at <irrelevant-project-path>/node_modules/next-auth/dist/server/routes/callback.js:28:364
at new Promise (<anonymous>)
at <irrelevant-project-path>/node_modules/next-auth/dist/server/routes/callback.js:28:97
at <irrelevant-project-path>/node_modules/next-auth/dist/server/routes/callback.js:302:17
https://next-auth.js.org/errors#callback_oauth_error
Additional context
No additional context I can think of.
Documentation feedback
Documentation refers to searching through online documentation, code comments and issue history. The example project refers to next-auth-example.
I believe the current option for this is state: true (and that useState is from an earlier v3 beta).
It's safe to use that option if a particular provider is doing something unexpected.
The only built in Provider I know has a problem with it is Apple.
The token used to verify state is generated is from the csrfToken - if that isn't being set for any reason then you might run into a problem, but in v3 you shouldn't be able to start an authorisation flow without csrfToken being set.
Gotcha, state: false is working for this.
Is the gist of this, then, that the csrfToken provides basically the same functionality as state?
I will close this. If I find out anything useful about what Azure B2C is doing I'll follow up. Thanks!
Hey @BenjaminWFox-Lumedic , so far connecting with b2c has been a tedious process.
Any chance you could share your full setup for using next-auth with AD b2c?
@RobbyUitbeijerse I created a minimal example repo and wrote up the steps I took to set it up - take a look, let me know if anything in it is off: https://benjaminwfox.com/blog/tech/how-to-configure-azure-b2c-with-nextjs
@BenjaminWFox-Lumedic With the above config code accessToken is undefined, were you able to make it work?.
@WaysToGo my example is using JWTs, so accessToken will not be generated (see warning here).
I believe you will have to use a database for your scenario, but I have not explored that route so can't comment specifically on implementation details :(
@WaysToGo in case this is relevant, I hadn't noticed before but there is the option to include 'Identity Provider Access Token' as a claim in the Azure B2C user flow.
I didn't have that box checked in my walkthrough, unsure if checking it (if you haven't already) would help in your case.
@BenjaminWFox I was able to get the access token by using a different scope URL, which is mentioned here
https://docs.microsoft.com/en-us/azure/active-directory-b2c/add-web-api-application?tabs=app-reg-ga
current scope config looks something like this
scope :https://${tenantName}.onmicrosoft.com/api/demo.read openid offline_access,
@BenjaminWFox @WaysToGo I have read several times both of your setups and I have setup my own on azure and looks like I cannot even get the access token from azure. Which bring me to the question do I actually need this access token to work with my api? If I have refresh token it means that I can control user when I need and how I need, but if the token expires it should self logout from a system, correct? sorry for those odd questions I am new to Azure and NextAuth.

@gyto23 There is an extra step needed to get an access token from Azure B2C (assuming you're using B2C) - you'll need to follow the steps from this tutorial documentation: https://docs.microsoft.com/en-us/azure/active-directory-b2c/access-tokens
The main points are:
I would not use the refresh token as any means of authentication/authorization. I'm not versed enough to tell you _why_ it's a bad idea, but I'm confident that it is :)
I have an updated config I can share with you which may help. Following the steps in the link above to get the access token is worth the extra effort, as it allows you to much better control the B2C JWT, which is separate and distinct from the NextAuth JWT.
In the jwt callback with this setup the account has an accessToken (which is the B2C JWT) and refreshToken both of which I store on the NextAuth JWT. Then it's easy to use the NextAuth API in my NextJS API to get the accessToken from the JWT and pass it to my backend api.
Additionally, you can see the logic in there that checks for imminent expiration of the accessToken and then silently gets a new token if needed. If you instead wanted to _not_ refresh the token you could just revoke the users NextAuth session at that point.
import NextAuth from 'next-auth'
// import Providers from 'next-auth/providers'
const tenantName = process.env.B2C_AUTH_TENANT_NAME
const loginFlow = process.env.B2C_LOGIN_FLOW_NAME
const maxAge = Number(process.env.CLIENT_SESSION_MAX_AGE)
const jwtSecret = process.env.NEXTAUTH_JWT_SECRET
const appSecret = process.env.NEXTAUTH_APP_SECRET
const clientId = process.env.B2C_AUTH_CLIENT_ID
const clientSecret = process.env.B2C_AUTH_CLIENT_SECRET
const apiAccessClaim = process.env.B2C_API_ACCESS_CLAIM
const signingKey = process.env.NEXTAUTH_SIGNING_KEY
const encryptionKey = process.env.NEXTAUTH_ENCRYPTION_KEY
const encryption = process.env.NEXTAUTH_ENCRYPT_JWT
const tokenUrl = `https://${tenantName}.b2clogin.com/${tenantName}.onmicrosoft.com/${loginFlow}/oauth2/v2.0/token`
const options = {
session: {
jwt: true,
maxAge,
},
jwt: {
secret: jwtSecret,
encryption,
signingKey,
encryptionKey,
},
secret: appSecret,
pages: {
signOut: '/auth/signout',
},
providers: [
{
id: 'azureb2c',
name: 'Azure B2C',
type: 'oauth',
version: '2.0',
debug: true,
scope: `https://${tenantName}.onmicrosoft.com/api/${apiAccessClaim} offline_access openid`,
params: {
grant_type: 'authorization_code',
},
accessTokenUrl: tokenUrl,
requestTokenUrl: tokenUrl,
authorizationUrl: `https://${tenantName}.b2clogin.com/${tenantName}.onmicrosoft.com/${loginFlow}/oauth2/v2.0/authorize?response_type=code+id_token+token&response_mode=form_post`,
profileUrl: 'https://graph.microsoft.com/oidc/userinfo',
profile: (profile) => {
console.debug('\n')
console.debug('~~ PROFILE', profile)
console.debug('\n')
// The NextAuth `user` object available to the client
return {
id: profile.oid,
name: `${profile.given_name} ${profile.family_name}`,
email: profile.emails.length ? profile.emails[0] : null,
image: undefined,
}
},
clientId,
clientSecret,
idToken: true,
state: false,
},
],
callbacks: {
// eslint-disable-next-line no-unused-vars
jwt: async (token, user, account, profile, isNewUser) => {
const now = parseInt(Date.now() / 1000, 10)
const tokenExpiryPaddingSeconds = 30
const isSignIn = !!user
// Add auth_time to token on signin in
if (isSignIn) {
// eslint-disable-next-line no-param-reassign
token.b2c = {
accessToken: account.accessToken,
refreshToken: account.refreshToken,
iat: profile.iat,
exp: profile.exp,
}
}
console.debug('\n Token expires / refreshes in: ', token.b2c.exp - now, ' / ', (token.b2c.exp - tokenExpiryPaddingSeconds) - now)
/**
* Add some extra seconds to refresh before it is completely expired to avoid
* any edge case scenarios where the token expires in transit if it is almost
* expired, but validated prior to an API call, and then sent in the
* Authorization header and expires.
*/
if (token.b2c.exp - tokenExpiryPaddingSeconds < now) {
const refreshQuery = `?grant_type=refresh_token&refresh_token=${token.b2c.refreshToken}&scope=https://${tenantName}.onmicrosoft.com/api/${apiAccessClaim} offline_access openid&client_id=${process.env.B2C_AUTH_CLIENT_ID}&redirect_uri=urn:ietf:wg:oauth:2.0:oob&client_secret=${process.env.B2C_AUTH_CLIENT_SECRET}`
const response = await fetch(`${tokenUrl}${refreshQuery}`, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
})
try {
const result = await response.json()
// eslint-disable-next-line no-param-reassign
token.b2c = {
accessToken: result.access_token,
refreshToken: result.refresh_token,
iat: result.not_before,
exp: result.expires_on,
}
}
catch (e) {
console.error('There was an error trying to refresh the accessToken')
console.error(e)
}
}
return Promise.resolve(token)
},
},
}
export default (req, res) => NextAuth(req, res, options)
@BenjaminWFox Thank you for your feedback, this is really helps. I hope you going to update the tutorial for the Azure setup for those people who searching similar implementation
Is it possible to use state with azure b2c? Without it you can only redirect back to the home page and you cant handle things like "Forgot my Password"
@BenjaminWFox weird question, why dont make azure b2c and azure ad to be part of the providers in the nextAuth this thing will popup multiple times, isnt it easy to make for user instruction for the setup and have it in it?
Most helpful comment
@RobbyUitbeijerse I created a minimal example repo and wrote up the steps I took to set it up - take a look, let me know if anything in it is off: https://benjaminwfox.com/blog/tech/how-to-configure-azure-b2c-with-nextjs