@auth/core
Version:
Authentication for the Web.
555 lines (489 loc) • 17.5 kB
text/typescript
// 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 { state } from "./oauth/checks.js"
import { createHash } from "../../utils/web.js"
import type { AdapterSession } from "../../../adapters.js"
import type {
Account,
Authenticator,
InternalOptions,
RequestInternal,
ResponseInternal,
User,
} from "../../../types.js"
import type { Cookie, SessionStore } from "../../utils/cookie.js"
import {
assertInternalOptionsWebAuthn,
verifyAuthenticate,
verifyRegister,
} from "../../utils/webauthn-utils.js"
/** Handle callbacks from login services */
export async function callback(
request: RequestInternal,
options: InternalOptions,
sessionStore: SessionStore,
cookies: Cookie[]
): Promise<ResponseInternal> {
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 params =
provider.authorization?.url.searchParams.get("response_mode") ===
"form_post"
? body
: query
// If we have a state and we are on a redirect proxy, we try to parse it
// and see if it contains a valid origin to redirect to. If it does, we
// redirect the user to that origin with the original state.
if (options.isOnRedirectProxy && params?.state) {
// NOTE: We rely on the state being encrypted using a shared secret
// between the proxy and the original server.
const parsedState = await state.decode(params.state, options)
const shouldRedirect =
parsedState?.origin &&
new URL(parsedState.origin).origin !== options.url.origin
if (shouldRedirect) {
const proxyRedirect = `${parsedState.origin}?${new URLSearchParams(params)}`
logger.debug("Proxy redirecting to", proxyRedirect)
return { redirect: proxyRedirect, cookies }
}
}
const authorizationResult = await handleOAuth(
params,
request.cookies,
options
)
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 as AdapterSession).sessionToken,
options: {
...options.cookies.sessionToken.options,
expires: (session as AdapterSession).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 paramToken = query?.token as string | undefined
const paramIdentifier = query?.email as string | undefined
if (!paramToken) {
const e = new TypeError(
"Missing token. The sign-in URL was manually opened without token or the link was not sent correctly in the email.",
{ cause: { hasToken: !!paramToken } }
)
e.name = "Configuration"
throw e
}
const secret = provider.secret ?? options.secret
// @ts-expect-error -- Verified in `assertConfig`.
const invite = await adapter.useVerificationToken({
// @ts-expect-error User-land adapters might decide to omit the identifier during lookup
identifier: paramIdentifier, // TODO: Drop this requirement for lookup in official adapters too
token: await createHash(`${paramToken}${secret}`),
})
const hasInvite = !!invite
const expired = hasInvite && invite.expires.valueOf() < Date.now()
const invalidInvite =
!hasInvite ||
expired ||
// 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) throw new Verification({ hasInvite, expired })
const { identifier } = invite
const user = (await adapter!.getUserByEmail(identifier)) ?? {
id: crypto.randomUUID(),
email: identifier,
emailVerified: null,
}
const account: Account = {
providerAccountId: user.email,
userId: user.id,
type: "email" as const,
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 as AdapterSession).sessionToken,
options: {
...options.cookies.sessionToken.options,
expires: (session as AdapterSession).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,
} satisfies Account
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: User
let account: Account
let authenticator: Authenticator | undefined
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 as AdapterSession).sessionToken,
options: {
...options.cookies.sessionToken.options,
expires: (session as AdapterSession).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 as Error, { provider: provider.id })
logger.debug("callback route error details", { method, query, body })
throw error
}
}
async function handleAuthorized(
params: Parameters<InternalOptions["callbacks"]["signIn"]>[0],
config: InternalOptions
): Promise<string | undefined> {
let authorized
const { signIn, redirect } = config.callbacks
try {
authorized = await signIn(params)
} catch (e) {
if (e instanceof AuthError) throw e
throw new AccessDenied(e as Error)
}
if (!authorized) throw new AccessDenied("AccessDenied")
if (typeof authorized !== "string") return
return await redirect({ url: authorized, baseUrl: config.url.origin })
}