UNPKG

@auth/core

Version:

Authentication for the Web.

333 lines (305 loc) 13.3 kB
import { AccountNotLinked, OAuthAccountNotLinked } from "../../../errors.js" import { fromDate } from "../../utils/date.js" import type { AdapterAccount, AdapterSession, AdapterUser, } from "../../../adapters.js" import type { Account, InternalOptions, User } from "../../../types.js" import type { JWT } from "../../../jwt.js" import type { OAuthConfig } from "../../../providers/index.js" import type { SessionToken } from "../../utils/cookie.js" /** * This function handles the complex flow of signing users in, and either creating, * linking (or not linking) accounts depending on if the user is currently logged * in, if they have account already and the authentication mechanism they are using. * * It prevents insecure behaviour, such as linking OAuth accounts unless a user is * signed in and authenticated with an existing valid account. * * All verification (e.g. OAuth flows or email address verification flows) are * done prior to this handler being called to avoid additional complexity in this * handler. */ export async function handleLoginOrRegister( sessionToken: SessionToken, _profile: User | AdapterUser | { email: string }, _account: AdapterAccount | Account | null, options: InternalOptions ) { // Input validation if (!_account?.providerAccountId || !_account.type) throw new Error("Missing or invalid provider account") if (!["email", "oauth", "oidc", "webauthn"].includes(_account.type)) throw new Error("Provider not supported") const { adapter, jwt, events, session: { strategy: sessionStrategy, generateSessionToken }, } = options // If no adapter is configured then we don't have a database and cannot // persist data; in this mode we just return a dummy session object. if (!adapter) { return { user: _profile as User, account: _account as Account } } const profile = _profile as AdapterUser let account = _account as AdapterAccount const { createUser, updateUser, getUser, getUserByAccount, getUserByEmail, linkAccount, createSession, getSessionAndUser, deleteSession, } = adapter let session: AdapterSession | JWT | null = null let user: AdapterUser | null = null let isNewUser = false const useJwtSession = sessionStrategy === "jwt" if (sessionToken) { if (useJwtSession) { try { const salt = options.cookies.sessionToken.name session = await jwt.decode({ ...jwt, token: sessionToken, salt }) if (session && "sub" in session && session.sub) { user = await getUser(session.sub) } } catch { // If session can't be verified, treat as no session } } else { const userAndSession = await getSessionAndUser(sessionToken) if (userAndSession) { session = userAndSession.session user = userAndSession.user } } } if (account.type === "email") { // If signing in with an email, check if an account with the same email address exists already const userByEmail = await getUserByEmail(profile.email) if (userByEmail) { // If they are not already signed in as the same user, this flow will // sign them out of the current session and sign them in as the new user if (user?.id !== userByEmail.id && !useJwtSession && sessionToken) { // Delete existing session if they are currently signed in as another user. // This will switch user accounts for the session in cases where the user was // already logged in with a different account. await deleteSession(sessionToken) } // Update emailVerified property on the user object user = await updateUser({ id: userByEmail.id, emailVerified: new Date(), }) await events.updateUser?.({ user }) } else { // Create user account if there isn't one for the email address already user = await createUser({ ...profile, emailVerified: new Date() }) await events.createUser?.({ user }) isNewUser = true } // Create new session session = useJwtSession ? {} : await createSession({ sessionToken: generateSessionToken(), userId: user.id, expires: fromDate(options.session.maxAge), }) return { session, user, isNewUser } } else if (account.type === "webauthn") { // Check if the account exists const userByAccount = await getUserByAccount({ providerAccountId: account.providerAccountId, provider: account.provider, }) if (userByAccount) { if (user) { // If the user is already signed in with this account, we don't need to do anything if (userByAccount.id === user.id) { const currentAccount: AdapterAccount = { ...account, userId: user.id } return { session, user, isNewUser, account: currentAccount } } // If the user is currently signed in, but the new account they are signing in // with is already associated with another user, then we cannot link them // and need to return an error. throw new AccountNotLinked( "The account is already associated with another user", { provider: account.provider } ) } // If there is no active session, but the account being signed in with is already // associated with a valid user then create session to sign the user in. session = useJwtSession ? {} : await createSession({ sessionToken: generateSessionToken(), userId: userByAccount.id, expires: fromDate(options.session.maxAge), }) const currentAccount: AdapterAccount = { ...account, userId: userByAccount.id, } return { session, user: userByAccount, isNewUser, account: currentAccount, } } else { // If the account doesn't exist, we'll create it if (user) { // If the user is already signed in and the account isn't already associated // with another user account then we can go ahead and link the accounts safely. await linkAccount({ ...account, userId: user.id }) await events.linkAccount?.({ user, account, profile }) // As they are already signed in, we don't need to do anything after linking them const currentAccount: AdapterAccount = { ...account, userId: user.id } return { session, user, isNewUser, account: currentAccount } } // If the user is not signed in and it looks like a new account then we // check there also isn't an user account already associated with the same // email address as the one in the request. const userByEmail = profile.email ? await getUserByEmail(profile.email) : null if (userByEmail) { // We don't trust user-provided email addresses, so we don't want to link accounts // if the email address associated with the new account is already associated with // an existing account. throw new AccountNotLinked( "Another account already exists with the same e-mail address", { provider: account.provider } ) } else { // If the current user is not logged in and the profile isn't linked to any user // accounts (by email or provider account id)... // // If no account matching the same [provider].id or .email exists, we can // create a new account for the user, link it to the OAuth account and // create a new session for them so they are signed in with it. user = await createUser({ ...profile }) } await events.createUser?.({ user }) await linkAccount({ ...account, userId: user.id }) await events.linkAccount?.({ user, account, profile }) session = useJwtSession ? {} : await createSession({ sessionToken: generateSessionToken(), userId: user.id, expires: fromDate(options.session.maxAge), }) const currentAccount: AdapterAccount = { ...account, userId: user.id } return { session, user, isNewUser: true, account: currentAccount } } } // If signing in with OAuth account, check to see if the account exists already const userByAccount = await getUserByAccount({ providerAccountId: account.providerAccountId, provider: account.provider, }) if (userByAccount) { if (user) { // If the user is already signed in with this account, we don't need to do anything if (userByAccount.id === user.id) { return { session, user, isNewUser } } // If the user is currently signed in, but the new account they are signing in // with is already associated with another user, then we cannot link them // and need to return an error. throw new OAuthAccountNotLinked( "The account is already associated with another user", { provider: account.provider } ) } // If there is no active session, but the account being signed in with is already // associated with a valid user then create session to sign the user in. session = useJwtSession ? {} : await createSession({ sessionToken: generateSessionToken(), userId: userByAccount.id, expires: fromDate(options.session.maxAge), }) return { session, user: userByAccount, isNewUser } } else { const { provider: p } = options as InternalOptions<"oauth" | "oidc"> const { type, provider, providerAccountId, userId, ...tokenSet } = account const defaults = { providerAccountId, provider, type, userId } account = Object.assign(p.account(tokenSet) ?? {}, defaults) if (user) { // If the user is already signed in and the OAuth account isn't already associated // with another user account then we can go ahead and link the accounts safely. await linkAccount({ ...account, userId: user.id }) await events.linkAccount?.({ user, account, profile }) // As they are already signed in, we don't need to do anything after linking them return { session, user, isNewUser } } // If the user is not signed in and it looks like a new OAuth account then we // check there also isn't an user account already associated with the same // email address as the one in the OAuth profile. // // This step is often overlooked in OAuth implementations, but covers the following cases: // // 1. It makes it harder for someone to accidentally create two accounts. // e.g. by signin in with email, then again with an oauth account connected to the same email. // 2. It makes it harder to hijack a user account using a 3rd party OAuth account. // e.g. by creating an oauth account then changing the email address associated with it. // // It's quite common for services to automatically link accounts in this case, but it's // better practice to require the user to sign in *then* link accounts to be sure // someone is not exploiting a problem with a third party OAuth service. // // OAuth providers should require email address verification to prevent this, but in // practice that is not always the case; this helps protect against that. const userByEmail = profile.email ? await getUserByEmail(profile.email) : null if (userByEmail) { const provider = options.provider as OAuthConfig<any> if (provider?.allowDangerousEmailAccountLinking) { // If you trust the oauth provider to correctly verify email addresses, you can opt-in to // account linking even when the user is not signed-in. user = userByEmail } else { // We end up here when we don't have an account with the same [provider].id *BUT* // we do already have an account with the same email address as the one in the // OAuth profile the user has just tried to sign in with. // // We don't want to have two accounts with the same email address, and we don't // want to link them in case it's not safe to do so, so instead we prompt the user // to sign in via email to verify their identity and then link the accounts. throw new OAuthAccountNotLinked( "Another account already exists with the same e-mail address", { provider: account.provider } ) } } else { // If the current user is not logged in and the profile isn't linked to any user // accounts (by email or provider account id)... // // If no account matching the same [provider].id or .email exists, we can // create a new account for the user, link it to the OAuth account and // create a new session for them so they are signed in with it. user = await createUser({ ...profile, emailVerified: null }) } await events.createUser?.({ user }) await linkAccount({ ...account, userId: user.id }) await events.linkAccount?.({ user, account, profile }) session = useJwtSession ? {} : await createSession({ sessionToken: generateSessionToken(), userId: user.id, expires: fromDate(options.session.maxAge), }) return { session, user, isNewUser: true } } }