next-auth
Version:
Authentication for Next.js
430 lines (385 loc) • 13.4 kB
text/typescript
import oAuthCallback from "../lib/oauth/callback"
import callbackHandler from "../lib/callback-handler"
import { hashToken } from "../lib/utils"
import getAdapterUserFromEmail from "../lib/email/getUserFromEmail"
import type { InternalOptions } from "../types"
import type { RequestInternal, ResponseInternal } from ".."
import type { Cookie, SessionStore } from "../lib/cookie"
import type { User } from "../.."
import type { AdapterSession } from "../../adapters"
/** Handle callbacks from login services */
export default async function callback(params: {
options: InternalOptions
query: RequestInternal["query"]
method: Required<RequestInternal>["method"]
body: RequestInternal["body"]
headers: RequestInternal["headers"]
cookies: RequestInternal["cookies"]
sessionStore: SessionStore
}): Promise<ResponseInternal> {
const { options, query, body, method, headers, sessionStore } = params
const {
provider,
adapter,
url,
callbackUrl,
pages,
jwt,
events,
callbacks,
session: { strategy: sessionStrategy, maxAge: sessionMaxAge },
logger,
} = options
const cookies: Cookie[] = []
const useJwtSession = sessionStrategy === "jwt"
if (provider.type === "oauth") {
try {
const {
profile,
account,
OAuthProfile,
cookies: oauthCookies,
} = await oAuthCallback({
query,
body,
method,
options,
cookies: params.cookies,
})
if (oauthCookies.length) cookies.push(...oauthCookies)
try {
// Make it easier to debug when adding a new provider
logger.debug("OAUTH_CALLBACK_RESPONSE", {
profile,
account,
OAuthProfile,
})
// If we don't have a profile object then either something went wrong
// or the user cancelled signing in. We don't know which, so we just
// direct the user to the signin page for now. We could do something
// else in future.
//
// Note: In oAuthCallback an error is logged with debug info, so it
// should at least be visible to developers what happened if it is an
// error with the provider.
if (!profile || !account || !OAuthProfile) {
return { redirect: `${url}/signin`, cookies }
}
// Check if user is allowed to sign in
// Attempt to get Profile from OAuth provider details before invoking
// signIn callback - but if no user object is returned, that is fine
// (that just means it's a new user signing in for the first time).
let userOrProfile = profile
if (adapter) {
const { getUserByAccount } = adapter
const userByAccount = await getUserByAccount({
providerAccountId: account.providerAccountId,
provider: provider.id,
})
if (userByAccount) userOrProfile = userByAccount
}
try {
const isAllowed = await callbacks.signIn({
user: userOrProfile,
account,
profile: OAuthProfile,
})
if (!isAllowed) {
return { redirect: `${url}/error?error=AccessDenied`, cookies }
} else if (typeof isAllowed === "string") {
return { redirect: isAllowed, cookies }
}
} catch (error) {
return {
redirect: `${url}/error?error=${encodeURIComponent(
(error as Error).message,
)}`,
cookies,
}
}
// Sign user in
const { user, session, isNewUser } = await callbackHandler({
sessionToken: sessionStore.value,
profile,
account,
options,
})
if (useJwtSession) {
const defaultToken = {
name: user.name,
email: user.email,
picture: user.image,
sub: user.id?.toString(),
}
const token = await callbacks.jwt({
token: defaultToken,
user,
account,
profile: OAuthProfile,
isNewUser,
trigger: isNewUser ? "signUp" : "signIn",
})
// Encode token
const newToken = await jwt.encode({ ...jwt, token })
// Set cookie expiry date
const cookieExpires = new Date()
cookieExpires.setTime(cookieExpires.getTime() + sessionMaxAge * 1000)
const sessionCookies = sessionStore.chunk(newToken, {
expires: cookieExpires,
})
cookies.push(...sessionCookies)
} else {
// Save Session Token in cookie
cookies.push({
name: options.cookies.sessionToken.name,
value: (session as AdapterSession).sessionToken,
options: {
...options.cookies.sessionToken.options,
expires: (session as AdapterSession).expires,
},
})
}
// @ts-expect-error
await events.signIn?.({ user, account, profile, isNewUser })
// Handle first logins on new accounts
// e.g. option to send users to a new account landing page on initial login
// Note that the callback URL is preserved, so the journey can still be resumed
if (isNewUser && pages.newUser) {
return {
redirect: `${pages.newUser}${
pages.newUser.includes("?") ? "&" : "?"
}callbackUrl=${encodeURIComponent(callbackUrl)}`,
cookies,
}
}
// Callback URL is already verified at this point, so safe to use if specified
return { redirect: callbackUrl, cookies }
} catch (error) {
if ((error as Error).name === "AccountNotLinkedError") {
// If the email on the account is already linked, but not with this OAuth account
return {
redirect: `${url}/error?error=OAuthAccountNotLinked`,
cookies,
}
} else if ((error as Error).name === "CreateUserError") {
return { redirect: `${url}/error?error=OAuthCreateAccount`, cookies }
}
logger.error("OAUTH_CALLBACK_HANDLER_ERROR", error as Error)
return { redirect: `${url}/error?error=Callback`, cookies }
}
} catch (error) {
if ((error as Error).name === "OAuthCallbackError") {
logger.error("OAUTH_CALLBACK_ERROR", {
error: error as Error,
providerId: provider.id,
})
return { redirect: `${url}/error?error=OAuthCallback`, cookies }
}
logger.error("OAUTH_CALLBACK_ERROR", error as Error)
return { redirect: `${url}/error?error=Callback`, cookies }
}
} else if (provider.type === "email") {
try {
const paramToken = query?.token as string | undefined
const paramIdentifier = query?.email as string | undefined
// If token is missing, the sign-in URL was manually opened without this param or the `sendVerificationRequest` method did not send the link correctly in the email.
if (!paramToken) {
return { redirect: `${url}/error?error=configuration`, cookies }
}
// @ts-expect-error -- Verified in `assertConfig`. adapter: Adapter<true>
const invite = await adapter.useVerificationToken({
// @ts-expect-error User-land adapters might decide to omit the identifier during lookup
identifier: paramIdentifier,
token: hashToken(paramToken, options),
})
const invalidInvite =
!invite ||
invite.expires.valueOf() < Date.now() ||
// The user might have configured the link to not contain the identifier
// so we only compare if it exists
(paramIdentifier && invite.identifier !== paramIdentifier)
if (invalidInvite) {
return { redirect: `${url}/error?error=Verification`, cookies }
}
const profile = await getAdapterUserFromEmail({
email: invite.identifier,
adapter,
})
const account = {
providerAccountId: profile.email,
type: "email" as const,
provider: provider.id,
}
// Check if user is allowed to sign in
try {
const signInCallbackResponse = await callbacks.signIn({
user: profile,
account,
})
if (!signInCallbackResponse) {
return { redirect: `${url}/error?error=AccessDenied`, cookies }
} else if (typeof signInCallbackResponse === "string") {
return { redirect: signInCallbackResponse, cookies }
}
} catch (error) {
return {
redirect: `${url}/error?error=${encodeURIComponent(
(error as Error).message,
)}`,
cookies,
}
}
// Sign user in
const { user, session, isNewUser } = await callbackHandler({
sessionToken: sessionStore.value,
profile,
account,
options,
})
if (useJwtSession) {
const defaultToken = {
name: user.name,
email: user.email,
picture: user.image,
sub: user.id?.toString(),
}
const token = await callbacks.jwt({
token: defaultToken,
user,
account,
isNewUser,
trigger: isNewUser ? "signUp" : "signIn",
})
// Encode token
const newToken = await jwt.encode({ ...jwt, token })
// Set cookie expiry date
const cookieExpires = new Date()
cookieExpires.setTime(cookieExpires.getTime() + sessionMaxAge * 1000)
const sessionCookies = sessionStore.chunk(newToken, {
expires: cookieExpires,
})
cookies.push(...sessionCookies)
} else {
// Save Session Token in cookie
cookies.push({
name: options.cookies.sessionToken.name,
value: (session as AdapterSession).sessionToken,
options: {
...options.cookies.sessionToken.options,
expires: (session as AdapterSession).expires,
},
})
}
await events.signIn?.({ user, account, isNewUser })
// Handle first logins on new accounts
// e.g. option to send users to a new account landing page on initial login
// Note that the callback URL is preserved, so the journey can still be resumed
if (isNewUser && pages.newUser) {
return {
redirect: `${pages.newUser}${
pages.newUser.includes("?") ? "&" : "?"
}callbackUrl=${encodeURIComponent(callbackUrl)}`,
cookies,
}
}
// Callback URL is already verified at this point, so safe to use if specified
return { redirect: callbackUrl, cookies }
} catch (error) {
if ((error as Error).name === "CreateUserError") {
return { redirect: `${url}/error?error=EmailCreateAccount`, cookies }
}
logger.error("CALLBACK_EMAIL_ERROR", error as Error)
return { redirect: `${url}/error?error=Callback`, cookies }
}
} else if (provider.type === "credentials" && method === "POST") {
const credentials = body
let user: User | null
try {
user = await provider.authorize(credentials, {
query,
body,
headers,
method,
})
if (!user) {
return {
status: 401,
redirect: `${url}/error?${new URLSearchParams({
error: "CredentialsSignin",
provider: provider.id,
})}`,
cookies,
}
}
} catch (error) {
return {
status: 401,
redirect: `${url}/error?error=${encodeURIComponent(
(error as Error).message,
)}`,
cookies,
}
}
/** @type {import("src").Account} */
const account = {
providerAccountId: user.id,
type: "credentials",
provider: provider.id,
}
try {
const isAllowed = await callbacks.signIn({
user,
// @ts-expect-error
account,
credentials,
})
if (!isAllowed) {
return {
status: 403,
redirect: `${url}/error?error=AccessDenied`,
cookies,
}
} else if (typeof isAllowed === "string") {
return { redirect: isAllowed, cookies }
}
} catch (error) {
return {
redirect: `${url}/error?error=${encodeURIComponent(
(error as Error).message,
)}`,
cookies,
}
}
const defaultToken = {
name: user.name,
email: user.email,
picture: user.image,
sub: user.id?.toString(),
}
const token = await callbacks.jwt({
token: defaultToken,
user,
// @ts-expect-error
account,
isNewUser: false,
trigger: "signIn",
})
// Encode token
const newToken = await jwt.encode({ ...jwt, token })
// Set cookie expiry date
const cookieExpires = new Date()
cookieExpires.setTime(cookieExpires.getTime() + sessionMaxAge * 1000)
const sessionCookies = sessionStore.chunk(newToken, {
expires: cookieExpires,
})
cookies.push(...sessionCookies)
// @ts-expect-error
await events.signIn?.({ user, account })
return { redirect: callbackUrl, cookies }
}
return {
status: 500,
body: `Error: Callback for provider type ${provider.type} not supported`,
cookies,
}
}