From 111d5fc572b5616649683daf0f2cac384bc1c51a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bal=C3=A1zs=20Orb=C3=A1n?= Date: Mon, 12 Jul 2021 00:30:24 +0200 Subject: [PATCH] feat(events): use named params for all event callbacks (#2342) Unified API for all of our user-facing methods. NOTE: `events.error` has been removed. This method has never been called in the core, so it did actually nothing. If you want to log errors to a third-party, check out the [`logger`](https://next-auth.js.org/configuration/options#logger) option instead. BREAKING CHANGE: Two event signatures changed to use named params, `signOut` and `updateUser`: ```diff // [...nextauth].js ... events: { - signOut(tokenOrSession), + signOut({ token, session }), // token if using JWT, session if DB persisted sessions. - updateUser(user) + updateUser({ user }) } ``` --- src/adapters/error-handler.js | 12 +--- src/lib/errors.js | 8 +++ src/server/index.js | 10 ++-- src/server/lib/callback-handler.js | 17 ++---- src/server/lib/default-events.js | 45 ++++++++------- src/server/lib/dispatch-event.js | 9 --- src/server/lib/oauth/callback.js | 10 ++-- src/server/lib/signin/oauth.js | 3 +- src/server/routes/callback.js | 7 +-- src/server/routes/session.js | 5 +- src/server/routes/signout.js | 5 +- types/index.d.ts | 88 ++++++++++++------------------ types/tests/server.test.ts | 15 ++--- www/docs/configuration/events.md | 31 +++++------ 14 files changed, 115 insertions(+), 150 deletions(-) delete mode 100644 src/server/lib/dispatch-event.js diff --git a/src/adapters/error-handler.js b/src/adapters/error-handler.js index f111deaaad..2dee788849 100644 --- a/src/adapters/error-handler.js +++ b/src/adapters/error-handler.js @@ -1,4 +1,4 @@ -import { UnknownError } from "../lib/errors" +import { capitalize, UnknownError, upperSnake } from "../lib/errors" /** * Handles adapter induced errors. @@ -9,7 +9,7 @@ import { UnknownError } from "../lib/errors" export default function adapterErrorHandler(adapter, logger) { return Object.keys(adapter).reduce((acc, method) => { const name = capitalize(method) - const code = upperSnake(name, adapter.displayName) + const code = `${adapter.displayName ?? "ADAPTER"}_${upperSnake(name)}` const adapterMethod = adapter[method] acc[method] = async (...args) => { @@ -26,11 +26,3 @@ export default function adapterErrorHandler(adapter, logger) { return acc }, {}) } - -function capitalize(s) { - return `${s[0].toUpperCase()}${s.slice(1)}` -} - -function upperSnake(s, prefix = "ADAPTER") { - return `${prefix}_${s.replace(/([A-Z])/g, "_$1")}`.toUpperCase() -} diff --git a/src/lib/errors.js b/src/lib/errors.js index 3bac54613a..fd267b9230 100644 --- a/src/lib/errors.js +++ b/src/lib/errors.js @@ -32,3 +32,11 @@ export class OAuthCallbackError extends UnknownError { export class AccountNotLinkedError extends UnknownError { name = "AccountNotLinkedError" } + +export function upperSnake(s) { + return s.replace(/([A-Z])/g, "_$1").toUpperCase() +} + +export function capitalize(s) { + return `${s[0].toUpperCase()}${s.slice(1)}` +} diff --git a/src/server/index.js b/src/server/index.js index e4d00771b4..a437aa8880 100644 --- a/src/server/index.js +++ b/src/server/index.js @@ -2,7 +2,7 @@ import jwt from "../lib/jwt" import parseUrl from "../lib/parse-url" import logger, { setLogger } from "../lib/logger" import * as cookie from "./lib/cookie" -import * as defaultEvents from "./lib/default-events" +import { withErrorHandling, defaultEvents } from "./lib/default-events" import * as defaultCallbacks from "./lib/default-callbacks" import parseProviders from "./lib/providers" import * as routes from "./routes" @@ -124,10 +124,10 @@ async function NextAuthHandler(req, res, userOptions) { ...userOptions.jwt, }, // Event messages - events: { - ...defaultEvents, - ...userOptions.events, - }, + events: withErrorHandling( + { ...defaultEvents, ...userOptions.events }, + logger + ), // Callback functions callbacks: { ...defaultCallbacks, diff --git a/src/server/lib/callback-handler.js b/src/server/lib/callback-handler.js index 114c052f30..646889b853 100644 --- a/src/server/lib/callback-handler.js +++ b/src/server/lib/callback-handler.js @@ -1,5 +1,4 @@ import { AccountNotLinkedError } from "../../lib/errors" -import dispatchEvent from "../lib/dispatch-event" import adapterErrorHandler from "../../adapters/error-handler" /** @@ -104,12 +103,12 @@ export default async function callbackHandler( // Update emailVerified property on the user object const currentDate = new Date() user = await updateUser({ ...userByEmail, emailVerified: currentDate }) - await dispatchEvent(events.updateUser, user) + await events.updateUser({ user }) } else { // Create user account if there isn't one for the email address already const currentDate = new Date() user = await createUser({ ...profile, emailVerified: currentDate }) - await dispatchEvent(events.createUser, user) + await events.createUser({ user }) isNewUser = true } @@ -168,10 +167,7 @@ export default async function callbackHandler( providerAccount.accessToken, providerAccount.accessTokenExpires ) - await dispatchEvent(events.linkAccount, { - user, - providerAccount: providerAccount, - }) + await events.linkAccount({ user, providerAccount }) // As they are already signed in, we don't need to do anything after linking them return { @@ -218,7 +214,7 @@ export default async function callbackHandler( // create a new account for the user, link it to the OAuth acccount and // create a new session for them so they are signed in with it. user = await createUser(profile) - await dispatchEvent(events.createUser, user) + await events.createUser({ user }) await linkAccount( user.id, @@ -229,10 +225,7 @@ export default async function callbackHandler( providerAccount.accessToken, providerAccount.accessTokenExpires ) - await dispatchEvent(events.linkAccount, { - user, - providerAccount: providerAccount, - }) + await events.linkAccount({ user, providerAccount }) session = useJwtSession ? {} : await createSession(user) isNewUser = true diff --git a/src/server/lib/default-events.js b/src/server/lib/default-events.js index caa14794f9..a5c7f7c339 100644 --- a/src/server/lib/default-events.js +++ b/src/server/lib/default-events.js @@ -1,23 +1,30 @@ -/** Event triggered on successful sign in */ -export async function signIn (message) {} +import { upperSnake } from "../../lib/errors" -/** Event triggered on sign out */ -export async function signOut (message) {} - -/** Event triggered on user creation */ -export async function createUser (message) {} - -/** Event triggered when a user object is updated */ -export async function updateUser (message) {} - -/** Event triggered when an account is linked to a user */ -export async function linkAccount (message) {} - -/** Event triggered when a session is active */ -export async function session (message) {} +/** @type {import("types").EventCallbacks} */ +export const defaultEvents = { + signIn() {}, + signOut() {}, + createUser() {}, + updateUser() {}, + linkAccount() {}, + session() {}, +} /** - * @TODO Event triggered when something goes wrong in an authentication flow - * This event may be fired multiple times when an error occurs + * Wraps an object of methods and adds error handling. + * @param {import("types").EventCallbacks} methods + * @param {import("types").LoggerInstance} logger + * @return {import("types").EventCallbacks} */ -export async function error (message) {} +export function withErrorHandling(methods, logger) { + return Object.entries(methods).reduce((acc, [name, method]) => { + acc[name] = async (...args) => { + try { + return await method(...args) + } catch (e) { + logger.error(`${upperSnake(name)}_EVENT_ERROR`, e) + } + } + return acc + }, {}) +} diff --git a/src/server/lib/dispatch-event.js b/src/server/lib/dispatch-event.js deleted file mode 100644 index 0c5740c305..0000000000 --- a/src/server/lib/dispatch-event.js +++ /dev/null @@ -1,9 +0,0 @@ -import logger from '../../lib/logger' - -export default async function dispatchEvent (event, message) { - try { - await event(message) - } catch (e) { - logger.error('EVENT_ERROR', e) - } -} diff --git a/src/server/lib/oauth/callback.js b/src/server/lib/oauth/callback.js index 6b9e7e6356..5e5d7595e9 100644 --- a/src/server/lib/oauth/callback.js +++ b/src/server/lib/oauth/callback.js @@ -1,11 +1,10 @@ import { decode as jwtDecode } from "jsonwebtoken" import oAuthClient from "./client" -import logger from "../../../lib/logger" import { OAuthCallbackError } from "../../../lib/errors" /** @param {import("types/internals").NextAuthRequest} req */ export default async function oAuthCallback(req) { - const { provider, pkce } = req.options + const { provider, pkce, logger } = req.options const client = oAuthClient(provider) if (provider.version?.startsWith("2.")) { @@ -59,7 +58,7 @@ export default async function oAuthCallback(req) { profileData = await client.get(provider, tokens.accessToken, tokens) } - return getProfile({ profileData, provider, tokens, user }) + return getProfile({ profileData, provider, tokens, user }, logger) } catch (error) { logger.error("OAUTH_GET_ACCESS_TOKEN_ERROR", { error, @@ -88,7 +87,7 @@ export default async function oAuthCallback(req) { tokens.oauth_token_secret ) - return getProfile({ profileData, tokens, provider }) + return getProfile({ profileData, tokens, provider }, logger) } catch (error) { logger.error("OAUTH_V1_GET_ACCESS_TOKEN_ERROR", error) throw error @@ -116,8 +115,9 @@ export default async function oAuthCallback(req) { * provider: import("../..").Provider * user?: object * }} profileParams + * @param {import("types").LoggerInstance} logger */ -async function getProfile({ profileData, tokens, provider, user }) { +async function getProfile({ profileData, tokens, provider, user }, logger) { try { // Convert profileData into an object if it's a string if (typeof profileData === "string") { diff --git a/src/server/lib/signin/oauth.js b/src/server/lib/signin/oauth.js index 3f08e3b578..f32aa4ae29 100644 --- a/src/server/lib/signin/oauth.js +++ b/src/server/lib/signin/oauth.js @@ -1,9 +1,8 @@ import oAuthClient from "../oauth/client" -import logger from "../../../lib/logger" /** @param {import("types/internals").NextAuthRequest} req */ export default async function getAuthorizationUrl(req) { - const { provider } = req.options + const { provider, logger } = req.options delete req.query?.nextauth const params = { diff --git a/src/server/routes/callback.js b/src/server/routes/callback.js index 510581fcc3..b64a380300 100644 --- a/src/server/routes/callback.js +++ b/src/server/routes/callback.js @@ -1,7 +1,6 @@ import oAuthCallback from "../lib/oauth/callback" import callbackHandler from "../lib/callback-handler" import * as cookie from "../lib/cookie" -import dispatchEvent from "../lib/dispatch-event" import adapterErrorHandler from "../../adapters/error-handler" /** @@ -133,7 +132,7 @@ export default async function callback(req, res) { }) } - await dispatchEvent(events.signIn, { user, account, isNewUser }) + 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 @@ -275,7 +274,7 @@ export default async function callback(req, res) { }) } - await dispatchEvent(events.signIn, { user, account, isNewUser }) + 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 @@ -397,7 +396,7 @@ export default async function callback(req, res) { ...cookies.sessionToken.options, }) - await dispatchEvent(events.signIn, { user, account }) + await events.signIn({ user, account }) return res.redirect(callbackUrl || baseUrl) } diff --git a/src/server/routes/session.js b/src/server/routes/session.js index c15542a150..bad322e08d 100644 --- a/src/server/routes/session.js +++ b/src/server/routes/session.js @@ -1,5 +1,4 @@ import * as cookie from "../lib/cookie" -import dispatchEvent from "../lib/dispatch-event" import adapterErrorHandler from "../../adapters/error-handler" /** @@ -61,7 +60,7 @@ export default async function session(req, res) { ...cookies.sessionToken.options, }) - await dispatchEvent(events.session, { session, token }) + await events.session({ session, token }) } catch (error) { // If JWT not verifiable, make sure the cookie for it is removed and return empty object logger.error("JWT_SESSION_ERROR", error) @@ -110,7 +109,7 @@ export default async function session(req, res) { ...cookies.sessionToken.options, }) - await dispatchEvent(events.session, { session: sessionPayload }) + await events.session({ session: sessionPayload }) } else if (sessionToken) { // If sessionToken was found set but it's not valid for a session then // remove the sessionToken cookie from browser. diff --git a/src/server/routes/signout.js b/src/server/routes/signout.js index 8e5e9ee336..dd759936b3 100644 --- a/src/server/routes/signout.js +++ b/src/server/routes/signout.js @@ -1,5 +1,4 @@ import * as cookie from "../lib/cookie" -import dispatchEvent from "../lib/dispatch-event" import adapterErrorHandler from "../../adapters/error-handler" /** @@ -16,7 +15,7 @@ export default async function signout(req, res) { // Dispatch signout event try { const decodedJwt = await jwt.decode({ ...jwt, token: sessionToken }) - await dispatchEvent(events.signOut, decodedJwt) + await events.signOut({ token: decodedJwt }) } catch (error) { // Do nothing if decoding the JWT fails } @@ -30,7 +29,7 @@ export default async function signout(req, res) { try { // Dispatch signout event const session = await getSession(sessionToken) - await dispatchEvent(events.signOut, session) + await events.signOut({ session }) } catch (error) { // Do nothing if looking up the session fails } diff --git a/types/index.d.ts b/types/index.d.ts index e5d0a737f6..d1c8f220e0 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -102,7 +102,7 @@ export interface NextAuthOptions { * * [Documentation](https://next-auth.js.org/configuration/options#events) | [Events documentation](https://next-auth.js.org/configuration/events) */ - events?: Partial + events?: Partial /** * You can use the adapter option to pass in your database adapter. * @@ -379,60 +379,44 @@ export interface CookiesOptions { pkceCodeVerifier: CookieOption } -/** [Documentation](https://next-auth.js.org/configuration/events) */ -export type EventCallback = ( - message: MessageType -) => Promise - -/** - * If using a `credentials` type auth, the user is the raw response from your - * credential provider. - * For other providers, you'll get the User object from your adapter, the account, - * and an indicator if the user was new to your Adapter. - */ -export interface SignInEventMessage { - user: User - account: Account - isNewUser?: boolean -} - -export interface LinkAccountEventMessage { - user: User - providerAccount: Record -} - -/** - * The various event callbacks you can register for from next-auth - */ -export interface CommonEventCallbacks { - signIn: EventCallback - createUser: EventCallback - updateUser: EventCallback - linkAccount: EventCallback - error: EventCallback -} -/** - * The event callbacks will take this form if you are using JWTs: - * signOut will receive the JWT and session will receive the session and JWT. - */ -export interface JWTEventCallbacks extends CommonEventCallbacks { - signOut: EventCallback - session: EventCallback<{ - session: Session - jwt: JWT - }> -} /** - * The event callbacks will take this form if you are using Sessions - * and not using JWTs: - * signOut will receive the underlying DB adapter's session object, and session - * will receive the NextAuth client session with extra data. + * The various event callbacks you can register for from next-auth + * + * [Documentation](https://next-auth.js.org/configuration/events) */ -export interface SessionEventCallbacks extends CommonEventCallbacks { - signOut: EventCallback - session: EventCallback<{ session: Session }> +export interface EventCallbacks { + /** + * If using a `credentials` type auth, the user is the raw response from your + * credential provider. + * For other providers, you'll get the User object from your adapter, the account, + * and an indicator if the user was new to your Adapter. + */ + signIn(message: { + user: User + account: Account + isNewUser?: boolean + }): Awaitable + /** + * The message object will contain one of these depending on + * if you use JWT or database persisted sessions: + * - `token`: The JWT token for this session. + * - `session`: The session object from your adapter that is being ended. + */ + signOut(message: { session: Session; token: JWT }): Awaitable + createUser(message: { user: User }): Awaitable + updateUser(message: { user: User }): Awaitable + linkAccount(message: { + user: User + providerAccount: Record + }): Awaitable + /** + * The message object will contain one of these depending on + * if you use JWT or database persisted sessions: + * - `token`: The JWT token for this session. + * - `session`: The session object from your adapter. + */ + session(message: { session: Session; token: JWT }): Awaitable } -export type EventCallbacks = JWTEventCallbacks | SessionEventCallbacks export type EventType = keyof EventCallbacks diff --git a/types/tests/server.test.ts b/types/tests/server.test.ts index 3b619ffa9a..b833f55161 100644 --- a/types/tests/server.test.ts +++ b/types/tests/server.test.ts @@ -167,25 +167,22 @@ const allConfig: NextAuthTypes.NextAuthOptions = { }, }, events: { - async signIn(message: NextAuthTypes.SignInEventMessage) { + async signIn(message) { return undefined }, - async signOut(message: NextAuthTypes.Session | null) { + async signOut(message) { return undefined }, - async createUser(message: NextAuthTypes.User) { + async createUser(message) { return undefined }, - async updateUser(message: NextAuthTypes.User) { + async updateUser(message) { return undefined }, - async linkAccount(message: NextAuthTypes.LinkAccountEventMessage) { + async linkAccount(message) { return undefined }, - async session(message: NextAuthTypes.Session) { - return undefined - }, - async error(message: any) { + async session(message) { return undefined }, }, diff --git a/www/docs/configuration/events.md b/www/docs/configuration/events.md index 791e7e23a9..7ea1752d7a 100644 --- a/www/docs/configuration/events.md +++ b/www/docs/configuration/events.md @@ -3,12 +3,12 @@ id: events title: Events --- -Events are asynchronous functions that do not return a response, they are useful for audit logs / reporting. +Events are asynchronous functions that do not return a response, they are useful for audit logs / reporting or handling any other side-effects. You can specify a handler for any of these events below, for debugging or for an audit log. :::note -Execution of your auth API will be blocked by an `await` on your event handler. If your event handler starts any burdensome work it should not block its own promise on that work. +The execution of your authentication API will be blocked by an `await` on your event handler. If your event handler starts any burdensome work it should not block its own promise on that work. ::: ## Events @@ -27,40 +27,37 @@ The message will be an object and contain: Sent when the user signs out. -The message object is the JWT, if using them, or the adapter session object for the session that is being ended. +The message object will contain one of these depending on if you use JWT or database persisted sessions: + +- `token`: The JWT token for this session. +- `session`: The session object from your adapter that is being ended ### createUser Sent when the adapter is told to create a new user. -The message object will be the user. +The message object will contain the user. ### updateUser Sent when the adapter is told to update an existing user. Currently this is only sent when the user verifies their email address. -The message object will be the user. +The message object will contain the user. ### linkAccount -Sent when an account in a given provider is linked to a user in our userbase. For example, when a user signs up with Twitter or when an existing user links their Google account. +Sent when an account in a given provider is linked to a user in our user database. For example, when a user signs up with Twitter or when an existing user links their Google account. -The message will be an object and contain: +The message object will contain: -- `user`: The user object from your adapter +- `user`: The user object from your adapter. - `providerAccount`: The object returned from the provider. ### session Sent at the end of a request for the current session. -The message will be an object and contain: - -- `session`: The session object from your adapter -- `jwt`: If using JWT, the token for this session. - -### error - -Sent when an error occurs +The message object will contain one of these depending on if you use JWT or database persisted sessions: -The message could be any object relevant to describing the error. +- `token`: The JWT token for this session. +- `session`: The session object from your adapter. \ No newline at end of file