@auth/core
Version:
Authentication for the Web.
410 lines (409 loc) • 19.3 kB
JavaScript
// 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 { 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 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.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 paramToken = query?.token;
const paramIdentifier = query?.email;
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 = {
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 });
}