We use next-auth to handle the OAuth flow. There are two critical files you should know:
- core.ts: Contains the logic to generate the OAuth URL and scopes for each integration.
- route.ts: Handles the OAuth callback and session logic.
In most cases, you only need to edit the core.ts
file. However, in some scenarios, you might need to use an interceptor
to ensure the returned data complies with NextAuth's validation, like in the Slack integration. For such cases, refer to the route.ts file. We'll explain the interceptor
logic in the following section.
NextAuth is built by default for the OIDC
flow, which means "Sign in with ". However, in our case, we want to "Connect to " to get the token and use it to access the user's data.
This use case creates additional challenges that require contributors to have a deeper understanding of NextAuth. For example, the default GitHub
provider follows the oauth
flow, while the default Slack
provider follows the oidc
flow. You can find these information in the source code of NextAuth.
- Understand the underlined logic of the target component, and make sure the scopes is correct
- Understand the OAuth flow, additional logic for the target component
- Create a custom OAuth provider that satisfies the target component's OAuth flow
- Create a custom interceptor to change the default behavior if needed
- Double check the received token match the component's requirement
Let's take the Slack integration as an example. It has this default configuration:
{
id: "slack",
name: "Slack",
type: "oidc",
issuer: "https://slack.com",
checks: ["nonce"],
style: { brandColor: "#611f69" },
options,
}
The crucial part is type: "oidc"
, which tells NextAuth this is an OIDC flow. NextAuth verifies the existing signature of the ID token using the issuer's public key or JSON Web Key Set (JWKS). Additionally, due to the default Slack configuration using the oidc
flow, it will call Slack's OIDC endpoint to get the token (https://slack.com/api/openid.connect.token).
This is the result with the default configuration:
{
ok: true,
access_token: '...',
token_type: 'bearer',
id_token: '...',
warning: 'superfluous_charset',
response_metadata: { warnings: [ 'superfluous_charset' ] },
provider: 'slack',
type: 'oidc',
providerAccountId: '...'
}
But what we want is this:
{
"ok": true,
"access_token": "...",
"token_type": "bot",
"scope": "...",
"bot_user_id": "...",
"app_id": "...",
"team": {
"name": "...",
"id": "..."
},
"enterprise": {
"name": "...",
"id": "..."
},
"authed_user": {
"id": "...",
"scope": "...",
"access_token": "...",
"token_type": "user"
}
}
To get the desired result, we need to create a custom OAuth provider and use an interceptor to change the default behavior.
Before implementation, read through the provider's OAuth guidelines. For Slack, the guidelines are here.
Key points from the guidelines:
- The authorization URL is
https://slack.com/oauth/v2/authorize
- The token URL is
https://slack.com/api/oauth.v2.access
- Slack has
bot_token
anduser_token
. Generally, without providing the user_scope, the endpoint will only return theuser_token
. - User info isn't included in the token URL response; we need to call another endpoint to get it:
https://slack.com/api/users.info
With this information, we can create a custom OAuth provider:
{
id: "slack",
name: "Slack",
type: "oauth",
clientId: String(process.env.SLACK_CLIENT_ID),
clientSecret: String(process.env.SLACK_CLIENT_SECRET),
authorization: {
url: "https://slack.com/oauth/v2/authorize",
params: {
scope: "...",
user_scope: "...",
granular_bot_scope: "1",
},
},
token: "https://slack.com/api/oauth.v2.access",
userinfo: {
url: "https://slack.com/api/users.info",
async request({ tokens }: { tokens: any; provider: any }) {
const profile = await fetch(
`https://slack.com/api/users.info?user=${tokens.authed_user.id}`,
{
headers: {
Authorization: `Bearer ${tokens.access_token}`,
"User-Agent": "authjs",
},
},
).then(async (res) => await res.json());
return profile.user;
},
},
}
Key points:
- Provide the
user_scope
in the authorization URL; otherwise, the endpoint will only return theuser_token
. Consult the component maintainer to better understand the logic when implementing these details. - Use the userInfo callback to get user info, which will be stored in the
profile
object.
However, NextAuth will complain that the token URL return uses the wrong token_type: bot
instead of bearer
.
To solve this, we need to create a custom interceptor
to change the default behavior.
The interceptor concept is simple: override the default fetch
behavior with the same fetch, but add custom logic within the function.
Here's an example for the Slack integration:
export const slackAccessTokenInterceptor =
(originalFetch: typeof fetch) =>
async (
url: Parameters<typeof fetch>[0],
options: Parameters<typeof fetch>[1] = {},
) => {
if (
url === "https://slack.com/api/oauth.v2.access" &&
options.method === "POST"
) {
const response = await originalFetch(url, options);
// Clone the response to be able to modify it
const clonedResponse = response.clone();
const body = await clonedResponse.json();
// Since we use https://slack.com/api/oauth.v2.access, the token_type is "bot" not "bearer"
// but next-auth expects the token_type to be "bearer"
body.token_type = "bearer";
// Create a new response with the modified body
const modifiedResponse = new Response(JSON.stringify(body), {
status: response.status,
statusText: response.statusText,
headers: response.headers,
});
// Add the original url to the response
return Object.defineProperty(modifiedResponse, "url", {
value: response.url,
});
}
return originalFetch(url, options);
};
Key points:
- Only change the behavior when the URL is
https://slack.com/api/oauth.v2.access
and the method isPOST
. - Clone the response and modify the body. (Be careful not to use the original response, as it should be consumed by NextAuth)
- Override the
token_type
tobearer
to comply with NextAuth's validation. Since we don't store any user Sessions, this isn't a critical issue.
Then adapt this interceptor in the route.ts file:
if (url.pathname === "/api/integration/callback/slack") {
/* Intercept the fetch request to patch access_token request to be oauth compliant */
global.fetch = slackAccessTokenInterceptor(fetch);
const response = handler.handlers.GET(req);
global.fetch = fetch;
return response;
}
Key points:
- Only intercept the request when the URL pathname is
/api/integration/callback/slack
. - Replace the global
fetch
with our interceptor. - After getting the response, restore the global
fetch
to the original one.
Now the Slack integration should work as expected.
In some cases like Slack, it's OAuth endpoint callback doesn't allow http request, we need to use ngrok to redirect the request to the local server. To test locally, you can follow these steps:
- Install ngrok:
brew install ngrok/ngrok/ngrok
- Run ngrok:
ngrok http 3000
- Replace the
NEXTAUTH_URL
in the .env.local file with the ngrok url plus our api path, it will looks like this:https://<ngrok_url>.ngrok-free.app/api/integration
- In the OAuth provider, replace the
redirect_uri
with the ngrok url plus our api path, it will looks like this:https://<ngrok_url>.ngrok-free.app/api/integration/callback/<integration_name>
- Build the app:
pnpm build
in the console folder - Run the app:
pnpm start
in the console folder - Test the integration connection in the console through ngrok url
Here are some side notes:
- Since we are using the standalone server, the environment is
production
and this will trigger the set-cookie functionality to setsecure
flag in the cookie. Which might result in the login session keep getting removed. This will not happened once you deploy it to https domain.