UNPKG

@auth/core

Version:

Authentication for the Web.

394 lines (393 loc) 18.2 kB
// TODO: Make this file smaller import { AuthError, AccessDenied, CallbackRouteError, CredentialsSignin, InvalidProvider, Verification, } from "../../../errors.js"; import { handleLoginOrRegister } from "./handle-login.js"; import { handleOAuth } from "./oauth/callback.js"; import { handleState } from "./oauth/checks.js"; import { createHash } from "../../utils/web.js"; import { assertInternalOptionsWebAuthn, verifyAuthenticate, verifyRegister, } from "../../utils/webauthn-utils.js"; /** Handle callbacks from login services */ export async function callback(request, options, sessionStore, cookies) { if (!options.provider) throw new InvalidProvider("Callback route called without provider"); const { query, body, method, headers } = request; const { provider, adapter, url, callbackUrl, pages, jwt, events, callbacks, session: { strategy: sessionStrategy, maxAge: sessionMaxAge }, logger, } = options; const useJwtSession = sessionStrategy === "jwt"; try { if (provider.type === "oauth" || provider.type === "oidc") { // Use body if the response mode is set to form_post. For all other cases, use query const payload = provider.authorization?.url.searchParams.get("response_mode") === "form_post" ? body : query; const { proxyRedirect, randomState } = handleState(payload, provider, options.isOnRedirectProxy); if (proxyRedirect) { logger.debug("proxy redirect", { proxyRedirect, randomState }); return { redirect: proxyRedirect }; } const authorizationResult = await handleOAuth(payload, request.cookies, options, randomState); if (authorizationResult.cookies.length) { cookies.push(...authorizationResult.cookies); } logger.debug("authorization result", authorizationResult); const { user: userFromProvider, account, profile: OAuthProfile, } = authorizationResult; // 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. // TODO: Handle user cancelling signin if (!userFromProvider || !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 userByAccount; if (adapter) { const { getUserByAccount } = adapter; userByAccount = await getUserByAccount({ providerAccountId: account.providerAccountId, provider: provider.id, }); } const redirect = await handleAuthorized({ user: userByAccount ?? userFromProvider, account, profile: OAuthProfile, }, options); if (redirect) return { redirect, cookies }; const { user, session, isNewUser } = await handleLoginOrRegister(sessionStore.value, userFromProvider, 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", }); // Clear cookies if token is null if (token === null) { cookies.push(...sessionStore.clean()); } else { const salt = options.cookies.sessionToken.name; // Encode token const newToken = await jwt.encode({ ...jwt, token, salt }); // 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.sessionToken, options: { ...options.cookies.sessionToken.options, expires: session.expires, }, }); } await events.signIn?.({ user, account, profile: OAuthProfile, 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("?") ? "&" : "?"}${new URLSearchParams({ callbackUrl })}`, cookies, }; } return { redirect: callbackUrl, cookies }; } else if (provider.type === "email") { const token = query?.token; const identifier = query?.email; if (!token || !identifier) { const e = new TypeError("Missing token or email. The sign-in URL was manually opened without token/identifier or the link was not sent correctly in the email.", { cause: { hasToken: !!token, hasEmail: !!identifier } }); e.name = "Configuration"; throw e; } const secret = provider.secret ?? options.secret; // @ts-expect-error -- Verified in `assertConfig`. const invite = await adapter.useVerificationToken({ identifier, token: await createHash(`${token}${secret}`), }); const hasInvite = !!invite; const expired = invite ? invite.expires.valueOf() < Date.now() : undefined; const invalidInvite = !hasInvite || expired; if (invalidInvite) throw new Verification({ hasInvite, expired }); const user = (await adapter.getUserByEmail(identifier)) ?? { id: crypto.randomUUID(), email: identifier, emailVerified: null, }; const account = { providerAccountId: user.email, userId: user.id, type: "email", provider: provider.id, }; const redirect = await handleAuthorized({ user, account }, options); if (redirect) return { redirect, cookies }; // Sign user in const { user: loggedInUser, session, isNewUser, } = await handleLoginOrRegister(sessionStore.value, user, account, options); if (useJwtSession) { const defaultToken = { name: loggedInUser.name, email: loggedInUser.email, picture: loggedInUser.image, sub: loggedInUser.id?.toString(), }; const token = await callbacks.jwt({ token: defaultToken, user: loggedInUser, account, isNewUser, trigger: isNewUser ? "signUp" : "signIn", }); // Clear cookies if token is null if (token === null) { cookies.push(...sessionStore.clean()); } else { const salt = options.cookies.sessionToken.name; // Encode token const newToken = await jwt.encode({ ...jwt, token, salt }); // 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.sessionToken, options: { ...options.cookies.sessionToken.options, expires: session.expires, }, }); } await events.signIn?.({ user: loggedInUser, 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("?") ? "&" : "?"}${new URLSearchParams({ callbackUrl })}`, cookies, }; } // Callback URL is already verified at this point, so safe to use if specified return { redirect: callbackUrl, cookies }; } else if (provider.type === "credentials" && method === "POST") { const credentials = body ?? {}; // TODO: Forward the original request as is, instead of reconstructing it Object.entries(query ?? {}).forEach(([k, v]) => url.searchParams.set(k, v)); const userFromAuthorize = await provider.authorize(credentials, // prettier-ignore new Request(url, { headers, method, body: JSON.stringify(body) })); const user = userFromAuthorize; if (!user) throw new CredentialsSignin(); else user.id = user.id?.toString() ?? crypto.randomUUID(); const account = { providerAccountId: user.id, type: "credentials", provider: provider.id, }; const redirect = await handleAuthorized({ user, account, credentials }, options); if (redirect) return { redirect, cookies }; const defaultToken = { name: user.name, email: user.email, picture: user.image, sub: user.id, }; const token = await callbacks.jwt({ token: defaultToken, user, account, isNewUser: false, trigger: "signIn", }); // Clear cookies if token is null if (token === null) { cookies.push(...sessionStore.clean()); } else { const salt = options.cookies.sessionToken.name; // Encode token const newToken = await jwt.encode({ ...jwt, token, salt }); // Set cookie expiry date const cookieExpires = new Date(); cookieExpires.setTime(cookieExpires.getTime() + sessionMaxAge * 1000); const sessionCookies = sessionStore.chunk(newToken, { expires: cookieExpires, }); cookies.push(...sessionCookies); } await events.signIn?.({ user, account }); return { redirect: callbackUrl, cookies }; } else if (provider.type === "webauthn" && method === "POST") { // Get callback action from request. It should be either "authenticate" or "register" const action = request.body?.action; if (typeof action !== "string" || (action !== "authenticate" && action !== "register")) { throw new AuthError("Invalid action parameter"); } // Return an error if the adapter is missing or if the provider // is not a webauthn provider. const localOptions = assertInternalOptionsWebAuthn(options); // Verify request to get user, account and authenticator let user; let account; let authenticator; switch (action) { case "authenticate": { const verified = await verifyAuthenticate(localOptions, request, cookies); user = verified.user; account = verified.account; break; } case "register": { const verified = await verifyRegister(options, request, cookies); user = verified.user; account = verified.account; authenticator = verified.authenticator; break; } } // Check if user is allowed to sign in await handleAuthorized({ user, account }, options); // Sign user in, creating them and their account if needed const { user: loggedInUser, isNewUser, session, account: currentAccount, } = await handleLoginOrRegister(sessionStore.value, user, account, options); if (!currentAccount) { // This is mostly for type checking. It should never actually happen. throw new AuthError("Error creating or finding account"); } // Create new authenticator if needed if (authenticator && loggedInUser.id) { await localOptions.adapter.createAuthenticator({ ...authenticator, userId: loggedInUser.id, }); } // Do the session registering dance if (useJwtSession) { const defaultToken = { name: loggedInUser.name, email: loggedInUser.email, picture: loggedInUser.image, sub: loggedInUser.id?.toString(), }; const token = await callbacks.jwt({ token: defaultToken, user: loggedInUser, account: currentAccount, isNewUser, trigger: isNewUser ? "signUp" : "signIn", }); // Clear cookies if token is null if (token === null) { cookies.push(...sessionStore.clean()); } else { const salt = options.cookies.sessionToken.name; // Encode token const newToken = await jwt.encode({ ...jwt, token, salt }); // 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.sessionToken, options: { ...options.cookies.sessionToken.options, expires: session.expires, }, }); } await events.signIn?.({ user: loggedInUser, account: currentAccount, 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("?") ? "&" : "?"}${new URLSearchParams({ callbackUrl })}`, cookies, }; } // Callback URL is already verified at this point, so safe to use if specified return { redirect: callbackUrl, cookies }; } throw new InvalidProvider(`Callback for provider type (${provider.type}) is not supported`); } catch (e) { if (e instanceof AuthError) throw e; const error = new CallbackRouteError(e, { provider: provider.id }); logger.debug("callback route error details", { method, query, body }); throw error; } } async function handleAuthorized(params, config) { let authorized; const { signIn, redirect } = config.callbacks; try { authorized = await signIn(params); } catch (e) { if (e instanceof AuthError) throw e; throw new AccessDenied(e); } if (!authorized) throw new AccessDenied("AccessDenied"); if (typeof authorized !== "string") return; return await redirect({ url: authorized, baseUrl: config.url.origin }); }