@auth/core
Advanced tools
Comparing version 0.25.1 to 0.26.0
@@ -165,3 +165,3 @@ /** | ||
import { ProviderType } from "./providers/index.js"; | ||
import type { Account, Awaitable, User } from "./types.js"; | ||
import type { Account, Authenticator, Awaitable, User } from "./types.js"; | ||
/** | ||
@@ -195,3 +195,3 @@ * A user represents a person who can sign in to the application. | ||
userId: string; | ||
type: Extract<ProviderType, "oauth" | "oidc" | "email">; | ||
type: Extract<ProviderType, "oauth" | "oidc" | "email" | "webauthn">; | ||
} | ||
@@ -242,2 +242,11 @@ /** | ||
/** | ||
* An authenticator represents a credential authenticator assigned to a user. | ||
*/ | ||
export interface AdapterAuthenticator extends Authenticator { | ||
/** | ||
* User ID of the authenticator. | ||
*/ | ||
userId: string; | ||
} | ||
/** | ||
* An adapter is an object with function properties (methods) that read and write data from a data source. | ||
@@ -357,2 +366,33 @@ * Think of these methods as a way to normalize the data layer to common interfaces that Auth.js can understand. | ||
}): Awaitable<VerificationToken | null>; | ||
/** | ||
* Get account by provider account id and provider. | ||
* | ||
* If an account is not found, the adapter must return `null`. | ||
*/ | ||
getAccount?(providerAccountId: AdapterAccount["providerAccountId"], provider: AdapterAccount["provider"]): Awaitable<AdapterAccount | null>; | ||
/** | ||
* Returns an authenticator from its credentialID. | ||
* | ||
* If an authenticator is not found, the adapter must return `null`. | ||
*/ | ||
getAuthenticator?(credentialID: AdapterAuthenticator['credentialID']): Awaitable<AdapterAuthenticator | null>; | ||
/** | ||
* Create a new authenticator. | ||
* | ||
* If the creation fails, the adapter must throw an error. | ||
*/ | ||
createAuthenticator?(authenticator: AdapterAuthenticator): Awaitable<AdapterAuthenticator>; | ||
/** | ||
* Returns all authenticators from a user. | ||
* | ||
* If a user is not found, the adapter should still return an empty array. | ||
* If the retrieval fails for some other reason, the adapter must throw an error. | ||
*/ | ||
listAuthenticatorsByUserId?(userId: AdapterAuthenticator['userId']): Awaitable<AdapterAuthenticator[]>; | ||
/** | ||
* Updates an authenticator's counter. | ||
* | ||
* If the update fails, the adapter must throw an error. | ||
*/ | ||
updateAuthenticatorCounter?(credentialID: AdapterAuthenticator['credentialID'], newCounter: AdapterAuthenticator['counter']): Awaitable<AdapterAuthenticator>; | ||
} | ||
@@ -359,0 +399,0 @@ declare module "next-auth/adapters" { |
type ErrorOptions = Error | Record<string, unknown>; | ||
type ErrorType = "AdapterError" | "AuthorizedCallbackError" | "CallbackRouteError" | "ErrorPageLoop" | "EventError" | "InvalidCallbackUrl" | "CredentialsSignin" | "InvalidEndpoints" | "InvalidCheck" | "JWTSessionError" | "MissingAdapter" | "MissingAdapterMethods" | "MissingAuthorize" | "MissingSecret" | "OAuthAccountNotLinked" | "OAuthCallbackError" | "OAuthProfileParseError" | "SessionTokenError" | "OAuthSignInError" | "EmailSignInError" | "SignOutError" | "UnknownAction" | "UnsupportedStrategy" | "InvalidProvider" | "UntrustedHost" | "Verification" | "MissingCSRF"; | ||
type ErrorType = "AdapterError" | "AuthorizedCallbackError" | "CallbackRouteError" | "ErrorPageLoop" | "EventError" | "InvalidCallbackUrl" | "CredentialsSignin" | "InvalidEndpoints" | "InvalidCheck" | "JWTSessionError" | "MissingAdapter" | "MissingAdapterMethods" | "MissingAuthorize" | "MissingSecret" | "OAuthAccountNotLinked" | "OAuthCallbackError" | "OAuthProfileParseError" | "SessionTokenError" | "OAuthSignInError" | "EmailSignInError" | "SignOutError" | "UnknownAction" | "UnsupportedStrategy" | "InvalidProvider" | "UntrustedHost" | "Verification" | "MissingCSRF" | "AccountNotLinked" | "DuplicateConditionalUI" | "MissingWebAuthnAutocomplete" | "WebAuthnVerificationError" | "ExperimentalFeatureNotEnabled"; | ||
/** | ||
@@ -305,3 +305,3 @@ * Base error class for all Auth.js errors. | ||
} | ||
/** Thrown when the callback endpoint was incorrectly called without a provider. */ | ||
/** Thrown when an endpoint was incorrectly called without a provider, or with an unsupported provider. */ | ||
export declare class InvalidProvider extends AuthError { | ||
@@ -345,3 +345,39 @@ static type: string; | ||
} | ||
/** | ||
* Thrown when multiple providers have `enableConditionalUI` set to `true`. | ||
* Only one provider can have this option enabled at a time. | ||
*/ | ||
export declare class DuplicateConditionalUI extends AuthError { | ||
static type: string; | ||
} | ||
/** | ||
* Thrown when a WebAuthn provider has `enableConditionalUI` set to `true` but no formField has `webauthn` in its autocomplete param. | ||
* | ||
* The `webauthn` autocomplete param is required for conditional UI to work. | ||
*/ | ||
export declare class MissingWebAuthnAutocomplete extends AuthError { | ||
static type: string; | ||
} | ||
/** | ||
* Thrown when a WebAuthn provider fails to verify a client response. | ||
*/ | ||
export declare class WebAuthnVerificationError extends AuthError { | ||
static type: string; | ||
} | ||
/** | ||
* Thrown when an Email address is already associated with an account | ||
* but the user is trying an account that is not linked to it. | ||
* | ||
* For security reasons, Auth.js does not automatically link accounts to existing accounts if the user is not signed in. | ||
*/ | ||
export declare class AccountNotLinked extends SignInError { | ||
static type: string; | ||
} | ||
/** | ||
* Thrown when an experimental feature is used but not enabled. | ||
*/ | ||
export declare class ExperimentalFeatureNotEnabled extends AuthError { | ||
static type: string; | ||
} | ||
export {}; | ||
//# sourceMappingURL=errors.d.ts.map |
@@ -321,3 +321,3 @@ /** | ||
UnsupportedStrategy.type = "UnsupportedStrategy"; | ||
/** Thrown when the callback endpoint was incorrectly called without a provider. */ | ||
/** Thrown when an endpoint was incorrectly called without a provider, or with an unsupported provider. */ | ||
export class InvalidProvider extends AuthError { | ||
@@ -361,1 +361,37 @@ } | ||
MissingCSRF.type = "MissingCSRF"; | ||
/** | ||
* Thrown when multiple providers have `enableConditionalUI` set to `true`. | ||
* Only one provider can have this option enabled at a time. | ||
*/ | ||
export class DuplicateConditionalUI extends AuthError { | ||
} | ||
DuplicateConditionalUI.type = "DuplicateConditionalUI"; | ||
/** | ||
* Thrown when a WebAuthn provider has `enableConditionalUI` set to `true` but no formField has `webauthn` in its autocomplete param. | ||
* | ||
* The `webauthn` autocomplete param is required for conditional UI to work. | ||
*/ | ||
export class MissingWebAuthnAutocomplete extends AuthError { | ||
} | ||
MissingWebAuthnAutocomplete.type = "MissingWebAuthnAutocomplete"; | ||
/** | ||
* Thrown when a WebAuthn provider fails to verify a client response. | ||
*/ | ||
export class WebAuthnVerificationError extends AuthError { | ||
} | ||
WebAuthnVerificationError.type = "WebAuthnVerificationError"; | ||
/** | ||
* Thrown when an Email address is already associated with an account | ||
* but the user is trying an account that is not linked to it. | ||
* | ||
* For security reasons, Auth.js does not automatically link accounts to existing accounts if the user is not signed in. | ||
*/ | ||
export class AccountNotLinked extends SignInError { | ||
} | ||
AccountNotLinked.type = "AccountNotLinked"; | ||
/** | ||
* Thrown when an experimental feature is used but not enabled. | ||
*/ | ||
export class ExperimentalFeatureNotEnabled extends AuthError { | ||
} | ||
ExperimentalFeatureNotEnabled.type = "ExperimentalFeatureNotEnabled"; |
@@ -284,3 +284,10 @@ /** | ||
*/ | ||
experimental?: Record<string, boolean>; | ||
experimental?: { | ||
/** | ||
* Enable WebAuthn support. | ||
* | ||
* @default false | ||
*/ | ||
enableWebAuthn?: boolean; | ||
}; | ||
/** | ||
@@ -287,0 +294,0 @@ * The base path of the Auth.js API endpoints. |
@@ -28,4 +28,9 @@ import type { AdapterAccount, AdapterSession, AdapterUser } from "../../../adapters.js"; | ||
isNewUser: boolean; | ||
account: AdapterAccount; | ||
} | { | ||
session: JWT | AdapterSession | null; | ||
user: AdapterUser; | ||
isNewUser: boolean; | ||
account?: undefined; | ||
}>; | ||
//# sourceMappingURL=handle-login.d.ts.map |
@@ -1,2 +0,2 @@ | ||
import { OAuthAccountNotLinked } from "../../../errors.js"; | ||
import { AccountNotLinked, OAuthAccountNotLinked } from "../../../errors.js"; | ||
import { fromDate } from "../../utils/date.js"; | ||
@@ -19,3 +19,3 @@ /** | ||
throw new Error("Missing or invalid provider account"); | ||
if (!["email", "oauth", "oidc"].includes(_account.type)) | ||
if (!["email", "oauth", "oidc", "webauthn"].includes(_account.type)) | ||
throw new Error("Provider not supported"); | ||
@@ -91,2 +91,78 @@ const { adapter, jwt, events, session: { strategy: sessionStrategy, generateSessionToken }, } = options; | ||
} | ||
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 = { ...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 = { ...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 = { ...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 = { ...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 | ||
@@ -93,0 +169,0 @@ const userByAccount = await getUserByAccount({ |
@@ -7,2 +7,3 @@ // TODO: Make this file smaller | ||
import { createHash } from "../../utils/web.js"; | ||
import { assertInternalOptionsWebAuthn, verifyAuthenticate, verifyRegister } from "../../utils/webauthn-utils.js"; | ||
/** Handle callbacks from login services */ | ||
@@ -259,2 +260,98 @@ export async function callback(request, options, sessionStore, 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`); | ||
@@ -261,0 +358,0 @@ } |
@@ -1,6 +0,7 @@ | ||
import type { CookiesOptions, InternalOptions, RequestInternal } from "../../../../types.js"; | ||
import type { CookiesOptions, InternalOptions, RequestInternal, User } from "../../../../types.js"; | ||
import type { Cookie } from "../../../utils/cookie.js"; | ||
import type { OAuthConfigInternal } from "../../../../providers/oauth.js"; | ||
import type { WebAuthnProviderType } from "../../../../providers/webauthn.js"; | ||
/** Returns a signed cookie. */ | ||
export declare function signCookie(type: keyof CookiesOptions, value: string, maxAge: number, options: InternalOptions<"oauth" | "oidc">, data?: any): Promise<Cookie>; | ||
export declare function signCookie(type: keyof CookiesOptions, value: string, maxAge: number, options: InternalOptions<"oauth" | "oidc" | WebAuthnProviderType>, data?: any): Promise<Cookie>; | ||
export declare const pkce: { | ||
@@ -65,2 +66,16 @@ create(options: InternalOptions<"oauth">): Promise<{ | ||
}; | ||
type WebAuthnChallengeCookie = { | ||
challenge: string; | ||
registerData?: User; | ||
}; | ||
export declare const webauthnChallenge: { | ||
create(options: InternalOptions<WebAuthnProviderType>, challenge: string, registerData?: User): Promise<{ | ||
cookie: Cookie; | ||
}>; | ||
/** | ||
* Returns challenge if present, | ||
*/ | ||
use(options: InternalOptions<WebAuthnProviderType>, cookies: RequestInternal["cookies"], resCookies: Cookie[]): Promise<WebAuthnChallengeCookie>; | ||
}; | ||
export {}; | ||
//# sourceMappingURL=checks.d.ts.map |
@@ -177,1 +177,33 @@ import * as jose from "jose"; | ||
} | ||
const WEBAUTHN_CHALLENGE_MAX_AGE = 60 * 15; // 15 minutes in seconds | ||
export const webauthnChallenge = { | ||
async create(options, challenge, registerData) { | ||
const maxAge = WEBAUTHN_CHALLENGE_MAX_AGE; | ||
const data = { challenge, registerData }; | ||
const cookie = await signCookie("webauthnChallenge", JSON.stringify(data), maxAge, options); | ||
return { cookie }; | ||
}, | ||
/** | ||
* Returns challenge if present, | ||
*/ | ||
async use(options, cookies, resCookies) { | ||
const challenge = cookies?.[options.cookies.webauthnChallenge.name]; | ||
if (!challenge) | ||
throw new InvalidCheck("Challenge cookie missing."); | ||
const value = await decode({ | ||
...options.jwt, | ||
token: challenge, | ||
salt: options.cookies.webauthnChallenge.name, | ||
}); | ||
if (!value?.value) | ||
throw new InvalidCheck("Challenge value could not be parsed."); | ||
// Clear the pkce code verifier cookie after use | ||
const cookie = { | ||
name: options.cookies.webauthnChallenge.name, | ||
value: "", | ||
options: { ...options.cookies.webauthnChallenge.options, maxAge: 0 }, | ||
}; | ||
resCookies.push(cookie); | ||
return JSON.parse(value.value); | ||
}, | ||
}; |
@@ -5,2 +5,3 @@ export { callback } from "./callback/index.js"; | ||
export { signOut } from "./signout.js"; | ||
export { webAuthnOptions } from "./webauthn-options.js"; | ||
//# sourceMappingURL=index.d.ts.map |
@@ -5,1 +5,2 @@ export { callback } from "./callback/index.js"; | ||
export { signOut } from "./signout.js"; | ||
export { webAuthnOptions } from "./webauthn-options.js"; |
@@ -42,2 +42,4 @@ import { UnknownAction } from "../errors.js"; | ||
return render.verifyRequest(); | ||
case "webauthn-options": | ||
return await actions.webAuthnOptions(request, options, sessionStore, cookies); | ||
default: | ||
@@ -44,0 +46,0 @@ } |
@@ -8,3 +8,4 @@ import { renderToString } from "preact-render-to-string"; | ||
import { UnknownAction } from "../../errors.js"; | ||
function send({ html, title, status, cookies, theme }) { | ||
import { getSimpleWebAuthnBrowserScriptTag } from "../../providers/webauthn.js"; | ||
function send({ html, title, status, cookies, theme, headTags }) { | ||
return { | ||
@@ -14,3 +15,3 @@ cookies, | ||
headers: { "Content-Type": "text/html" }, | ||
body: `<!DOCTYPE html><html lang="en"><head><meta charset="UTF-8"><meta http-equiv="X-UA-Compatible" content="IE=edge"><meta name="viewport" content="width=device-width, initial-scale=1.0"><style>${css}</style><title>${title}</title></head><body class="__next-auth-theme-${theme?.colorScheme ?? "auto"}"><div class="page">${renderToString(html)}</div></body></html>`, | ||
body: `<!DOCTYPE html><html lang="en"><head><meta charset="UTF-8"><meta http-equiv="X-UA-Compatible" content="IE=edge"><meta name="viewport" content="width=device-width, initial-scale=1.0"><style>${css}</style><title>${title}</title>${headTags ?? ""}</head><body class="__next-auth-theme-${theme?.colorScheme ?? "auto"}"><div class="page">${renderToString(html)}</div></body></html>`, | ||
}; | ||
@@ -23,3 +24,3 @@ } | ||
export default function renderPage(params) { | ||
const { url, theme, query, cookies, pages } = params; | ||
const { url, theme, query, cookies, pages, providers } = params; | ||
return { | ||
@@ -60,2 +61,9 @@ csrf(skip, options, cookies) { | ||
} | ||
// If we have a webauthn provider with conditional UI and | ||
// a simpleWebAuthnBrowserScript is defined, we need to | ||
// render the script in the page. | ||
const webauthnProvider = providers?.find((p) => p.type === "webauthn" && p.enableConditionalUI); | ||
const simpleWebAuthnBrowserScript = webauthnProvider ? | ||
getSimpleWebAuthnBrowserScriptTag(webauthnProvider) : | ||
undefined; | ||
return send({ | ||
@@ -72,2 +80,4 @@ cookies, | ||
(provider.type === "credentials" && provider.credentials) || | ||
// Only render webauthn type provider if formFields are defined | ||
(provider.type === "webauthn" && provider.formFields) || | ||
// Don't render other provider types | ||
@@ -81,2 +91,3 @@ false), | ||
title: "Sign In", | ||
headTags: simpleWebAuthnBrowserScript, | ||
}); | ||
@@ -83,0 +94,0 @@ }, |
@@ -1,2 +0,3 @@ | ||
import { jsx as _jsx, jsxs as _jsxs } from "preact/jsx-runtime"; | ||
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "preact/jsx-runtime"; | ||
import { webauthnScript } from "../utils/webauthn-client.js"; | ||
const signinErrors = { | ||
@@ -36,2 +37,10 @@ default: "Unable to sign in.", | ||
} | ||
function ConditionalUIScript(providerID) { | ||
const startConditionalUIScript = ` | ||
const currentURL = window.location.href; | ||
const authURL = currentURL.substring(0, currentURL.lastIndexOf('/')); | ||
(${webauthnScript})(authURL, "${providerID}"); | ||
`; | ||
return (_jsx(_Fragment, { children: _jsx("script", { dangerouslySetInnerHTML: { __html: startConditionalUIScript } }) })); | ||
} | ||
export default function SigninPage(props) { | ||
@@ -47,2 +56,3 @@ const { csrfToken, providers = [], callbackUrl, theme, email, error: errorType, } = props; | ||
const providerLogoPath = "https://authjs.dev/img/providers"; | ||
const conditionalUIProviderID = providers.find((provider) => provider.type === "webauthn" && provider.enableConditionalUI)?.id; | ||
return (_jsxs("div", { className: "signin", children: [theme?.brandColor && (_jsx("style", { dangerouslySetInnerHTML: { | ||
@@ -81,10 +91,13 @@ __html: `:root {--brand-color: ${theme.brandColor}}`, | ||
"--provider-dark-bg-hover": hexToRgba(bgDark, 0.8), | ||
}, tabIndex: 0, children: [logo && (_jsx("img", { loading: "lazy", height: 24, width: 24, id: "provider-logo", src: logo })), logoDark && (_jsx("img", { loading: "lazy", height: 24, width: 24, id: "provider-logo-dark", src: logoDark })), _jsxs("span", { children: ["Sign in with ", provider.name] })] })] })) : null, (provider.type === "email" || provider.type === "credentials") && | ||
}, tabIndex: 0, children: [logo && (_jsx("img", { loading: "lazy", height: 24, width: 24, id: "provider-logo", src: logo })), logoDark && (_jsx("img", { loading: "lazy", height: 24, width: 24, id: "provider-logo-dark", src: logoDark })), _jsxs("span", { children: ["Sign in with ", provider.name] })] })] })) : null, (provider.type === "email" || provider.type === "credentials" || provider.type === "webauthn") && | ||
i > 0 && | ||
providers[i - 1].type !== "email" && | ||
providers[i - 1].type !== "credentials" && _jsx("hr", {}), provider.type === "email" && (_jsxs("form", { action: provider.signinUrl, method: "POST", children: [_jsx("input", { type: "hidden", name: "csrfToken", value: csrfToken }), _jsx("label", { className: "section-header", htmlFor: `input-email-for-${provider.id}-provider`, children: "Email" }), _jsx("input", { id: `input-email-for-${provider.id}-provider`, autoFocus: true, type: "email", name: "email", value: email, placeholder: "email@example.com", required: true }), _jsxs("button", { id: "submitButton", type: "submit", tabIndex: 0, children: ["Sign in with ", provider.name] })] })), provider.type === "credentials" && (_jsxs("form", { action: provider.callbackUrl, method: "POST", children: [_jsx("input", { type: "hidden", name: "csrfToken", value: csrfToken }), Object.keys(provider.credentials).map((credential) => { | ||
providers[i - 1].type !== "credentials" && | ||
providers[i - 1].type !== "webauthn" && _jsx("hr", {}), provider.type === "email" && (_jsxs("form", { action: provider.signinUrl, method: "POST", children: [_jsx("input", { type: "hidden", name: "csrfToken", value: csrfToken }), _jsx("label", { className: "section-header", htmlFor: `input-email-for-${provider.id}-provider`, children: "Email" }), _jsx("input", { id: `input-email-for-${provider.id}-provider`, autoFocus: true, type: "email", name: "email", value: email, placeholder: "email@example.com", required: true }), _jsxs("button", { id: "submitButton", type: "submit", tabIndex: 0, children: ["Sign in with ", provider.name] })] })), provider.type === "credentials" && (_jsxs("form", { action: provider.callbackUrl, method: "POST", children: [_jsx("input", { type: "hidden", name: "csrfToken", value: csrfToken }), Object.keys(provider.credentials).map((credential) => { | ||
return (_jsxs("div", { children: [_jsx("label", { className: "section-header", htmlFor: `input-${credential}-for-${provider.id}-provider`, children: provider.credentials[credential].label ?? credential }), _jsx("input", { name: credential, id: `input-${credential}-for-${provider.id}-provider`, type: provider.credentials[credential].type ?? "text", placeholder: provider.credentials[credential].placeholder ?? "", ...provider.credentials[credential] })] }, `input-group-${provider.id}`)); | ||
}), _jsxs("button", { id: "submitButton", type: "submit", tabIndex: 0, children: ["Sign in with ", provider.name] })] })), (provider.type === "email" || provider.type === "credentials") && | ||
}), _jsxs("button", { id: "submitButton", type: "submit", tabIndex: 0, children: ["Sign in with ", provider.name] })] })), provider.type === "webauthn" && (_jsxs("form", { action: provider.callbackUrl, method: "POST", id: `${provider.id}-form`, children: [_jsx("input", { type: "hidden", name: "csrfToken", value: csrfToken }), Object.keys(provider.formFields).map((field) => { | ||
return (_jsxs("div", { children: [_jsx("label", { className: "section-header", htmlFor: `input-${field}-for-${provider.id}-provider`, children: provider.formFields[field].label ?? field }), _jsx("input", { name: field, "data-form-field": true, id: `input-${field}-for-${provider.id}-provider`, type: provider.formFields[field].type ?? "text", placeholder: provider.formFields[field].placeholder ?? "", ...provider.formFields[field] })] }, `input-group-${provider.id}`)); | ||
}), _jsxs("button", { id: `submitButton-${provider.id}`, type: "submit", tabIndex: 0, children: ["Sign in with ", provider.name] })] })), (provider.type === "email" || provider.type === "credentials" || provider.type === "webauthn") && | ||
i + 1 < providers.length && _jsx("hr", {})] }, provider.id)); | ||
})] })] })); | ||
})] }), conditionalUIProviderID && ConditionalUIScript(conditionalUIProviderID)] })); | ||
} |
@@ -1,3 +0,3 @@ | ||
declare const _default: ":root {\n --border-width: 1px;\n --border-radius: 0.5rem;\n --color-error: #c94b4b;\n --color-info: #157efb;\n --color-info-hover: #0f6ddb;\n --color-info-text: #fff;\n}\n\n.__next-auth-theme-auto,\n.__next-auth-theme-light {\n --color-background: #ececec;\n --color-background-hover: rgba(236, 236, 236, 0.8);\n --color-background-card: #fff;\n --color-text: #000;\n --color-primary: #444;\n --color-control-border: #bbb;\n --color-button-active-background: #f9f9f9;\n --color-button-active-border: #aaa;\n --color-separator: #ccc;\n}\n\n.__next-auth-theme-dark {\n --color-background: #161b22;\n --color-background-hover: rgba(22, 27, 34, 0.8);\n --color-background-card: #0d1117;\n --color-text: #fff;\n --color-primary: #ccc;\n --color-control-border: #555;\n --color-button-active-background: #060606;\n --color-button-active-border: #666;\n --color-separator: #444;\n}\n\n@media (prefers-color-scheme: dark) {\n .__next-auth-theme-auto {\n --color-background: #161b22;\n --color-background-hover: rgba(22, 27, 34, 0.8);\n --color-background-card: #0d1117;\n --color-text: #fff;\n --color-primary: #ccc;\n --color-control-border: #555;\n --color-button-active-background: #060606;\n --color-button-active-border: #666;\n --color-separator: #444;\n }\n\n button,\n a.button {\n color: var(--provider-dark-color, var(--color-primary));\n background-color: var(--provider-dark-bg, var(--color-background));\n }\n button:hover, a.button:hover {\n background-color: var(\n --provider-dark-bg-hover,\n var(--color-background-hover)\n ) !important;\n }\n #provider-logo {\n display: none !important;\n }\n #provider-logo-dark {\n width: 25px;\n display: block !important;\n }\n}\nhtml {\n box-sizing: border-box;\n}\n*,\n*:before,\n*:after {\n box-sizing: inherit;\n margin: 0;\n padding: 0;\n}\n\nbody {\n background-color: var(--color-background);\n margin: 0;\n padding: 0;\n font-family:\n ui-sans-serif,\n system-ui,\n -apple-system,\n BlinkMacSystemFont,\n \"Segoe UI\",\n Roboto,\n \"Helvetica Neue\",\n Arial,\n \"Noto Sans\",\n sans-serif,\n \"Apple Color Emoji\",\n \"Segoe UI Emoji\",\n \"Segoe UI Symbol\",\n \"Noto Color Emoji\";\n}\n\nh1 {\n margin-bottom: 1.5rem;\n padding: 0 1rem;\n font-weight: 400;\n color: var(--color-text);\n}\n\np {\n margin-bottom: 1.5rem;\n padding: 0 1rem;\n color: var(--color-text);\n}\n\nform {\n margin: 0;\n padding: 0;\n}\n\nlabel {\n font-weight: 500;\n text-align: left;\n margin-bottom: 0.25rem;\n display: block;\n color: var(--color-text);\n}\n\ninput[type] {\n box-sizing: border-box;\n display: block;\n width: 100%;\n padding: 0.5rem 1rem;\n border: var(--border-width) solid var(--color-control-border);\n background: var(--color-background-card);\n font-size: 1rem;\n border-radius: var(--border-radius);\n color: var(--color-text);\n}\n\ninput[type]:focus {\n box-shadow: none;\n }\n\np {\n font-size: 1.1rem;\n line-height: 2rem;\n}\n\na.button {\n text-decoration: none;\n line-height: 1rem;\n}\n\na.button:link,\n a.button:visited {\n background-color: var(--color-background);\n color: var(--color-primary);\n }\n\nbutton span {\n flex-grow: 1;\n}\n\nbutton,\na.button {\n padding: 0.75rem 1rem;\n color: var(--provider-color, var(--color-primary));\n background-color: var(--provider-bg);\n font-size: 1.1rem;\n min-height: 62px;\n border-color: rgba(0, 0, 0, 0.1);\n border-radius: var(--border-radius);\n transition: all 0.1s ease-in-out;\n font-weight: 500;\n position: relative;\n display: flex;\n align-items: center;\n justify-content: center;\n}\n\nbutton:hover, a.button:hover {\n background-color: var(--provider-bg-hover, var(--color-background-hover));\n cursor: pointer;\n }\n\nbutton:active, a.button:active {\n cursor: pointer;\n }\n\nbutton #provider-logo, a.button #provider-logo {\n width: 25px;\n display: block;\n }\n\nbutton #provider-logo-dark, a.button #provider-logo-dark {\n display: none;\n }\n\n#submitButton {\n color: var(--button-text-color, var(--color-info-text));\n background-color: var(--brand-color, var(--color-info));\n width: 100%;\n}\n\n#submitButton:hover {\n background-color: var(\n --button-hover-bg,\n var(--color-info-hover)\n ) !important;\n }\n\na.site {\n color: var(--color-primary);\n text-decoration: none;\n font-size: 1rem;\n line-height: 2rem;\n}\n\na.site:hover {\n text-decoration: underline;\n }\n\n.page {\n position: absolute;\n width: 100%;\n height: 100%;\n display: grid;\n place-items: center;\n margin: 0;\n padding: 0;\n box-sizing: border-box;\n}\n\n.page > div {\n text-align: center;\n }\n\n.error a.button {\n padding-left: 2rem;\n padding-right: 2rem;\n margin-top: 0.5rem;\n }\n\n.error .message {\n margin-bottom: 1.5rem;\n }\n\n.signin input[type=\"text\"] {\n margin-left: auto;\n margin-right: auto;\n display: block;\n }\n\n.signin hr {\n display: block;\n border: 0;\n border-top: 1px solid var(--color-separator);\n margin: 2rem auto 1rem auto;\n overflow: visible;\n }\n\n.signin hr::before {\n content: \"or\";\n background: var(--color-background-card);\n color: #888;\n padding: 0 0.4rem;\n position: relative;\n top: -0.7rem;\n }\n\n.signin .error {\n background: #f5f5f5;\n font-weight: 500;\n border-radius: 0.3rem;\n background: var(--color-error);\n }\n\n.signin .error p {\n text-align: left;\n padding: 0.5rem 1rem;\n font-size: 0.9rem;\n line-height: 1.2rem;\n color: var(--color-info-text);\n }\n\n.signin > div,\n .signin form {\n display: block;\n }\n\n.signin > div input[type], .signin form input[type] {\n margin-bottom: 0.5rem;\n }\n\n.signin > div button, .signin form button {\n width: 100%;\n }\n\n.signin .provider + .provider {\n margin-top: 1rem;\n }\n\n.logo {\n display: inline-block;\n max-width: 150px;\n margin: 1.25rem 0;\n max-height: 70px;\n}\n\n.card {\n background-color: var(--color-background-card);\n border-radius: 2rem;\n padding: 1.25rem 2rem;\n}\n\n.card .header {\n color: var(--color-primary);\n }\n\n.section-header {\n color: var(--color-text);\n}\n\n@media screen and (min-width: 450px) {\n .card {\n margin: 2rem 0;\n width: 368px;\n }\n}\n@media screen and (max-width: 450px) {\n .card {\n margin: 1rem 0;\n width: 343px;\n }\n}\n"; | ||
declare const _default: ":root {\n --border-width: 1px;\n --border-radius: 0.5rem;\n --color-error: #c94b4b;\n --color-info: #157efb;\n --color-info-hover: #0f6ddb;\n --color-info-text: #fff;\n}\n\n.__next-auth-theme-auto,\n.__next-auth-theme-light {\n --color-background: #ececec;\n --color-background-hover: rgba(236, 236, 236, 0.8);\n --color-background-card: #fff;\n --color-text: #000;\n --color-primary: #444;\n --color-control-border: #bbb;\n --color-button-active-background: #f9f9f9;\n --color-button-active-border: #aaa;\n --color-separator: #ccc;\n}\n\n.__next-auth-theme-dark {\n --color-background: #161b22;\n --color-background-hover: rgba(22, 27, 34, 0.8);\n --color-background-card: #0d1117;\n --color-text: #fff;\n --color-primary: #ccc;\n --color-control-border: #555;\n --color-button-active-background: #060606;\n --color-button-active-border: #666;\n --color-separator: #444;\n}\n\n@media (prefers-color-scheme: dark) {\n .__next-auth-theme-auto {\n --color-background: #161b22;\n --color-background-hover: rgba(22, 27, 34, 0.8);\n --color-background-card: #0d1117;\n --color-text: #fff;\n --color-primary: #ccc;\n --color-control-border: #555;\n --color-button-active-background: #060606;\n --color-button-active-border: #666;\n --color-separator: #444;\n }\n\n button,\n a.button {\n color: var(--provider-dark-color, var(--color-primary));\n background-color: var(--provider-dark-bg, var(--color-background));\n }\n :is(button,a.button):hover {\n background-color: var(\n --provider-dark-bg-hover,\n var(--color-background-hover)\n ) !important;\n }\n #provider-logo {\n display: none !important;\n }\n #provider-logo-dark {\n width: 25px;\n display: block !important;\n }\n}\nhtml {\n box-sizing: border-box;\n}\n*,\n*:before,\n*:after {\n box-sizing: inherit;\n margin: 0;\n padding: 0;\n}\n\nbody {\n background-color: var(--color-background);\n margin: 0;\n padding: 0;\n font-family:\n ui-sans-serif,\n system-ui,\n -apple-system,\n BlinkMacSystemFont,\n \"Segoe UI\",\n Roboto,\n \"Helvetica Neue\",\n Arial,\n \"Noto Sans\",\n sans-serif,\n \"Apple Color Emoji\",\n \"Segoe UI Emoji\",\n \"Segoe UI Symbol\",\n \"Noto Color Emoji\";\n}\n\nh1 {\n margin-bottom: 1.5rem;\n padding: 0 1rem;\n font-weight: 400;\n color: var(--color-text);\n}\n\np {\n margin-bottom: 1.5rem;\n padding: 0 1rem;\n color: var(--color-text);\n}\n\nform {\n margin: 0;\n padding: 0;\n}\n\nlabel {\n font-weight: 500;\n text-align: left;\n margin-bottom: 0.25rem;\n display: block;\n color: var(--color-text);\n}\n\ninput[type] {\n box-sizing: border-box;\n display: block;\n width: 100%;\n padding: 0.5rem 1rem;\n border: var(--border-width) solid var(--color-control-border);\n background: var(--color-background-card);\n font-size: 1rem;\n border-radius: var(--border-radius);\n color: var(--color-text);\n}\n\ninput[type]:focus {\n box-shadow: none;\n }\n\np {\n font-size: 1.1rem;\n line-height: 2rem;\n}\n\na.button {\n text-decoration: none;\n line-height: 1rem;\n}\n\na.button:link,\n a.button:visited {\n background-color: var(--color-background);\n color: var(--color-primary);\n }\n\nbutton span {\n flex-grow: 1;\n}\n\nbutton,\na.button {\n padding: 0.75rem 1rem;\n color: var(--provider-color, var(--color-primary));\n background-color: var(--provider-bg);\n font-size: 1.1rem;\n min-height: 62px;\n border-color: rgba(0, 0, 0, 0.1);\n border-radius: var(--border-radius);\n transition: all 0.1s ease-in-out;\n font-weight: 500;\n position: relative;\n display: flex;\n align-items: center;\n justify-content: center;\n}\n\n:is(button,a.button):hover {\n background-color: var(--provider-bg-hover, var(--color-background-hover));\n cursor: pointer;\n }\n\n:is(button,a.button):active {\n cursor: pointer;\n }\n\n:is(button,a.button) #provider-logo {\n width: 25px;\n display: block;\n }\n\n:is(button,a.button) #provider-logo-dark {\n display: none;\n }\n\n#submitButton {\n color: var(--button-text-color, var(--color-info-text));\n background-color: var(--brand-color, var(--color-info));\n width: 100%;\n}\n\n#submitButton:hover {\n background-color: var(\n --button-hover-bg,\n var(--color-info-hover)\n ) !important;\n }\n\na.site {\n color: var(--color-primary);\n text-decoration: none;\n font-size: 1rem;\n line-height: 2rem;\n}\n\na.site:hover {\n text-decoration: underline;\n }\n\n.page {\n position: absolute;\n width: 100%;\n height: 100%;\n display: grid;\n place-items: center;\n margin: 0;\n padding: 0;\n box-sizing: border-box;\n}\n\n.page > div {\n text-align: center;\n }\n\n.error a.button {\n padding-left: 2rem;\n padding-right: 2rem;\n margin-top: 0.5rem;\n }\n\n.error .message {\n margin-bottom: 1.5rem;\n }\n\n.signin input[type=\"text\"] {\n margin-left: auto;\n margin-right: auto;\n display: block;\n }\n\n.signin hr {\n display: block;\n border: 0;\n border-top: 1px solid var(--color-separator);\n margin: 2rem auto 1rem auto;\n overflow: visible;\n }\n\n.signin hr::before {\n content: \"or\";\n background: var(--color-background-card);\n color: #888;\n padding: 0 0.4rem;\n position: relative;\n top: -0.7rem;\n }\n\n.signin .error {\n background: #f5f5f5;\n font-weight: 500;\n border-radius: 0.3rem;\n background: var(--color-error);\n }\n\n.signin .error p {\n text-align: left;\n padding: 0.5rem 1rem;\n font-size: 0.9rem;\n line-height: 1.2rem;\n color: var(--color-info-text);\n }\n\n.signin > div,\n .signin form {\n display: block;\n }\n\n.signin > div input[type], .signin form input[type] {\n margin-bottom: 0.5rem;\n }\n\n.signin > div button, .signin form button {\n width: 100%;\n }\n\n.signin .provider + .provider {\n margin-top: 1rem;\n }\n\n.logo {\n display: inline-block;\n max-width: 150px;\n margin: 1.25rem 0;\n max-height: 70px;\n}\n\n.card {\n background-color: var(--color-background-card);\n border-radius: 2rem;\n padding: 1.25rem 2rem;\n}\n\n.card .header {\n color: var(--color-primary);\n }\n\n.section-header {\n color: var(--color-text);\n}\n\n@media screen and (min-width: 450px) {\n .card {\n margin: 2rem 0;\n width: 368px;\n }\n}\n@media screen and (max-width: 450px) {\n .card {\n margin: 1rem 0;\n width: 343px;\n }\n}\n"; | ||
export default _default; | ||
//# sourceMappingURL=styles.d.ts.map |
@@ -54,3 +54,3 @@ // Generated by `pnpm css` | ||
} | ||
button:hover, a.button:hover { | ||
:is(button,a.button):hover { | ||
background-color: var( | ||
@@ -180,3 +180,3 @@ --provider-dark-bg-hover, | ||
button:hover, a.button:hover { | ||
:is(button,a.button):hover { | ||
background-color: var(--provider-bg-hover, var(--color-background-hover)); | ||
@@ -186,7 +186,7 @@ cursor: pointer; | ||
button:active, a.button:active { | ||
:is(button,a.button):active { | ||
cursor: pointer; | ||
} | ||
button #provider-logo, a.button #provider-logo { | ||
:is(button,a.button) #provider-logo { | ||
width: 25px; | ||
@@ -196,3 +196,3 @@ display: block; | ||
button #provider-logo-dark, a.button #provider-logo-dark { | ||
:is(button,a.button) #provider-logo-dark { | ||
display: none; | ||
@@ -199,0 +199,0 @@ } |
@@ -10,2 +10,3 @@ const actions = [ | ||
"error", | ||
"webauthn-options", | ||
]; | ||
@@ -12,0 +13,0 @@ export function isAuthAction(action) { |
import { defaultCookies } from "./cookie.js"; | ||
import { InvalidCallbackUrl, InvalidEndpoints, MissingAdapter, MissingAdapterMethods, MissingAuthorize, MissingSecret, UnsupportedStrategy, UntrustedHost, } from "../../errors.js"; | ||
import { AuthError, DuplicateConditionalUI, ExperimentalFeatureNotEnabled, InvalidCallbackUrl, InvalidEndpoints, MissingAdapter, MissingAdapterMethods, MissingAuthorize, MissingSecret, MissingWebAuthnAutocomplete, UnsupportedStrategy, UntrustedHost, } from "../../errors.js"; | ||
let warned = false; | ||
@@ -12,4 +12,8 @@ function isValidHttpUrl(url, baseUrl) { | ||
} | ||
function isSemverString(version) { | ||
return /^v\d+(?:\.\d+){0,2}$/.test(version); | ||
} | ||
let hasCredentials = false; | ||
let hasEmail = false; | ||
let hasWebAuthn = false; | ||
const emailMethods = [ | ||
@@ -32,2 +36,12 @@ "createVerificationToken", | ||
]; | ||
const webauthnMethods = [ | ||
"createUser", | ||
"getUser", | ||
"linkAccount", | ||
"getAccount", | ||
"getAuthenticator", | ||
"createAuthenticator", | ||
"listAuthenticatorsByUserId", | ||
"updateAuthenticatorCounter", | ||
]; | ||
/** | ||
@@ -59,2 +73,4 @@ * Verify that the user configured Auth.js correctly. | ||
} | ||
// Keep track of webauthn providers that use conditional UI | ||
let hasConditionalUIProvider = false; | ||
for (const p of options.providers) { | ||
@@ -80,2 +96,21 @@ const provider = typeof p === "function" ? p() : p; | ||
hasEmail = true; | ||
else if (provider.type === "webauthn") { | ||
hasWebAuthn = true; | ||
// Validate simpleWebAuthnBrowserVersion | ||
if (provider.simpleWebAuthnBrowserVersion && !isSemverString(provider.simpleWebAuthnBrowserVersion)) { | ||
return new AuthError(`Invalid provider config for "${provider.id}": simpleWebAuthnBrowserVersion "${provider.simpleWebAuthnBrowserVersion}" must be a valid semver string.`); | ||
} | ||
if (provider.enableConditionalUI) { | ||
// Make sure only one webauthn provider has "enableConditionalUI" set to true | ||
if (hasConditionalUIProvider) { | ||
return new DuplicateConditionalUI(`Multiple webauthn providers have 'enableConditionalUI' set to True. Only one provider can have this option enabled at a time.`); | ||
} | ||
hasConditionalUIProvider = true; | ||
// Make sure at least one formField has "webauthn" in its autocomplete param | ||
const hasWebauthnFormField = Object.values(provider.formFields).some((f) => f.autocomplete && f.autocomplete.toString().indexOf("webauthn") > -1); | ||
if (!hasWebauthnFormField) { | ||
return new MissingWebAuthnAutocomplete(`Provider "${provider.id}" has 'enableConditionalUI' set to True, but none of its formFields have 'webauthn' in their autocomplete param.`); | ||
} | ||
} | ||
} | ||
} | ||
@@ -97,10 +132,8 @@ if (hasCredentials) { | ||
const { adapter, session } = options; | ||
if (hasEmail || | ||
session?.strategy === "database" || | ||
(!session?.strategy && adapter)) { | ||
let methods; | ||
let requiredMethods = []; | ||
if (hasEmail || session?.strategy === "database" || (!session?.strategy && adapter)) { | ||
if (hasEmail) { | ||
if (!adapter) | ||
return new MissingAdapter("Email login requires an adapter."); | ||
methods = emailMethods; | ||
requiredMethods.push(...emailMethods); | ||
} | ||
@@ -110,5 +143,19 @@ else { | ||
return new MissingAdapter("Database session requires an adapter."); | ||
methods = sessionMethods; | ||
requiredMethods.push(...sessionMethods); | ||
} | ||
const missing = methods.filter((m) => !adapter[m]); | ||
} | ||
if (hasWebAuthn) { | ||
// Log experimental warning | ||
if (options.experimental?.enableWebAuthn) { | ||
warnings.push("experimental-webauthn"); | ||
} | ||
else { | ||
return new ExperimentalFeatureNotEnabled("WebAuthn is an experimental feature. To enable it, set `experimental.enableWebAuthn` to `true` in your config."); | ||
} | ||
if (!adapter) | ||
return new MissingAdapter("WebAuthn requires an adapter."); | ||
requiredMethods.push(...webauthnMethods); | ||
} | ||
if (adapter) { | ||
const missing = requiredMethods.filter((m) => !(m in adapter)); | ||
if (missing.length) { | ||
@@ -115,0 +162,0 @@ return new MissingAdapterMethods(`Required adapter methods were missing: ${missing.join(", ")}`); |
@@ -80,2 +80,12 @@ import type { CookieOption, LoggerInstance, RequestInternal } from "../../types.js"; | ||
}; | ||
readonly webauthnChallenge: { | ||
readonly name: "__Secure-authjs.challenge" | "authjs.challenge"; | ||
readonly options: { | ||
readonly httpOnly: true; | ||
readonly sameSite: "lax"; | ||
readonly path: "/"; | ||
readonly secure: boolean; | ||
readonly maxAge: number; | ||
}; | ||
}; | ||
}; | ||
@@ -82,0 +92,0 @@ export interface Cookie extends CookieOption { |
@@ -105,2 +105,12 @@ var __classPrivateFieldSet = (this && this.__classPrivateFieldSet) || function (receiver, state, value, kind, f) { | ||
}, | ||
webauthnChallenge: { | ||
name: `${cookiePrefix}authjs.challenge`, | ||
options: { | ||
httpOnly: true, | ||
sameSite: "lax", | ||
path: "/", | ||
secure: useSecureCookies, | ||
maxAge: 60 * 15, // 15 minutes in seconds | ||
}, | ||
}, | ||
}; | ||
@@ -107,0 +117,0 @@ } |
@@ -1,2 +0,2 @@ | ||
export type WarningCode = "debug-enabled" | "csrf-disabled"; | ||
export type WarningCode = "debug-enabled" | "csrf-disabled" | "experimental-webauthn"; | ||
/** | ||
@@ -3,0 +3,0 @@ * Override any of the methods, and the rest will use the default logger. |
@@ -98,5 +98,5 @@ import { parse as parseCookie, serialize } from "cookie"; | ||
throw new UnknownAction(`Cannot parse action at ${pathname}`); | ||
if (providerId && !["signin", "callback"].includes(action)) | ||
if (providerId && !["signin", "callback", "webauthn-options"].includes(action)) | ||
throw new UnknownAction(`Cannot parse action at ${pathname}`); | ||
return { action, providerId }; | ||
} |
{ | ||
"name": "@auth/core", | ||
"version": "0.25.1", | ||
"version": "0.26.0", | ||
"description": "Authentication for the Web.", | ||
@@ -73,5 +73,9 @@ "keywords": [ | ||
"peerDependencies": { | ||
"@simplewebauthn/server": "^9.0.1", | ||
"nodemailer": "^6.8.0" | ||
}, | ||
"peerDependenciesMeta": { | ||
"@simplewebauthn/server": { | ||
"optional": true | ||
}, | ||
"nodemailer": { | ||
@@ -82,2 +86,3 @@ "optional": true | ||
"devDependencies": { | ||
"@simplewebauthn/browser": "v9.0.0", | ||
"@types/node": "18.11.10", | ||
@@ -88,3 +93,3 @@ "@types/nodemailer": "6.4.6", | ||
"postcss": "8.4.19", | ||
"postcss-nested": "6.0.0" | ||
"postcss-nesting": "^12.0.2" | ||
}, | ||
@@ -91,0 +96,0 @@ "scripts": { |
@@ -7,2 +7,3 @@ import { Profile } from "../types.js"; | ||
import type { OAuth2Config, OAuthConfig, OAuthProviderType, OIDCConfig } from "./oauth.js"; | ||
import { WebAuthnConfig, WebAuthnProviderType } from "./webauthn.js"; | ||
export * from "./credentials.js"; | ||
@@ -19,3 +20,3 @@ export * from "./email.js"; | ||
*/ | ||
export type ProviderType = "oidc" | "oauth" | "email" | "credentials"; | ||
export type ProviderType = "oidc" | "oauth" | "email" | "credentials" | WebAuthnProviderType; | ||
/** Shared across all {@link ProviderType} */ | ||
@@ -54,4 +55,4 @@ export interface CommonProviderOptions { | ||
*/ | ||
export type Provider<P extends Profile = any> = (((OIDCConfig<P> | OAuth2Config<P> | EmailConfig | CredentialsConfig) & InternalProviderOptions) | ((...args: any) => (OAuth2Config<P> | OIDCConfig<P> | EmailConfig | CredentialsConfig) & InternalProviderOptions)) & InternalProviderOptions; | ||
export type BuiltInProviders = Record<OAuthProviderType, (config: Partial<OAuthConfig<any>>) => OAuthConfig<any>> & Record<CredentialsProviderType, typeof CredentialsProvider> & Record<EmailProviderType, typeof EmailProvider>; | ||
export type Provider<P extends Profile = any> = (((OIDCConfig<P> | OAuth2Config<P> | EmailConfig | CredentialsConfig | WebAuthnConfig) & InternalProviderOptions) | ((...args: any) => (OAuth2Config<P> | OIDCConfig<P> | EmailConfig | CredentialsConfig | WebAuthnConfig) & InternalProviderOptions)) & InternalProviderOptions; | ||
export type BuiltInProviders = Record<OAuthProviderType, (config: Partial<OAuthConfig<any>>) => OAuthConfig<any>> & Record<CredentialsProviderType, typeof CredentialsProvider> & Record<EmailProviderType, typeof EmailProvider> & Record<WebAuthnProviderType, (config: Partial<WebAuthnConfig>) => WebAuthnConfig>; | ||
export type AppProviders = Array<Provider | ReturnType<BuiltInProviders[keyof BuiltInProviders]>>; | ||
@@ -63,3 +64,3 @@ export interface AppProvider extends CommonProviderOptions { | ||
export type RedirectableProviderType = "email" | "credentials"; | ||
export type BuiltInProviderType = RedirectableProviderType | OAuthProviderType; | ||
export type BuiltInProviderType = RedirectableProviderType | OAuthProviderType | WebAuthnProviderType; | ||
//# sourceMappingURL=index.d.ts.map |
@@ -1,2 +0,2 @@ | ||
export type OAuthProviderType = "42-school" | "apple" | "asgardeo" | "atlassian" | "auth0" | "authentik" | "azure-ad-b2c" | "azure-ad" | "azure-devops" | "battlenet" | "beyondidentity" | "box" | "boxyhq-saml" | "bungie" | "click-up" | "cognito" | "coinbase" | "descope" | "discord" | "dribbble" | "dropbox" | "duende-identity-server6" | "eveonline" | "facebook" | "faceit" | "foursquare" | "freshbooks" | "fusionauth" | "github" | "gitlab" | "google" | "hubspot" | "identity-server4" | "instagram" | "kakao" | "keycloak" | "line" | "linkedin" | "mailchimp" | "mailru" | "mastodon" | "mattermost" | "medium" | "naver" | "netlify" | "nodemailer" | "notion" | "okta" | "onelogin" | "ory-hydra" | "osso" | "osu" | "passage" | "patreon" | "pinterest" | "pipedrive" | "reddit" | "resend" | "salesforce" | "sendgrid" | "slack" | "spotify" | "strava" | "tiktok" | "todoist" | "trakt" | "twitch" | "twitter" | "united-effects" | "vk" | "wikimedia" | "wordpress" | "workos" | "yandex" | "zitadel" | "zoho" | "zoom"; | ||
export type OAuthProviderType = "42-school" | "apple" | "asgardeo" | "atlassian" | "auth0" | "authentik" | "azure-ad-b2c" | "azure-ad" | "azure-devops" | "battlenet" | "beyondidentity" | "box" | "boxyhq-saml" | "bungie" | "click-up" | "cognito" | "coinbase" | "descope" | "discord" | "dribbble" | "dropbox" | "duende-identity-server6" | "eveonline" | "facebook" | "faceit" | "foursquare" | "freshbooks" | "fusionauth" | "github" | "gitlab" | "google" | "hubspot" | "identity-server4" | "instagram" | "kakao" | "keycloak" | "line" | "linkedin" | "mailchimp" | "mailru" | "mastodon" | "mattermost" | "medium" | "naver" | "netlify" | "nodemailer" | "notion" | "okta" | "onelogin" | "ory-hydra" | "osso" | "osu" | "passage" | "passkey" | "patreon" | "pinterest" | "pipedrive" | "reddit" | "resend" | "salesforce" | "sendgrid" | "slack" | "spotify" | "strava" | "tiktok" | "todoist" | "trakt" | "twitch" | "twitter" | "united-effects" | "vk" | "webauthn" | "wikimedia" | "wordpress" | "workos" | "yandex" | "zitadel" | "zoho" | "zoom"; | ||
//# sourceMappingURL=oauth-types.d.ts.map |
@@ -166,3 +166,3 @@ /** | ||
import { ProviderType } from "./providers/index.js" | ||
import type { Account, Awaitable, User } from "./types.js" | ||
import type { Account, Authenticator, Awaitable, User } from "./types.js" | ||
// TODO: Discuss if we should expose methods to serialize and deserialize | ||
@@ -201,3 +201,3 @@ // the data? Many adapters share this logic, so it could be useful to | ||
userId: string | ||
type: Extract<ProviderType, "oauth" | "oidc" | "email"> | ||
type: Extract<ProviderType, "oauth" | "oidc" | "email" | "webauthn"> | ||
} | ||
@@ -251,2 +251,12 @@ | ||
/** | ||
* An authenticator represents a credential authenticator assigned to a user. | ||
*/ | ||
export interface AdapterAuthenticator extends Authenticator { | ||
/** | ||
* User ID of the authenticator. | ||
*/ | ||
userId: string | ||
} | ||
/** | ||
* An adapter is an object with function properties (methods) that read and write data from a data source. | ||
@@ -381,2 +391,44 @@ * Think of these methods as a way to normalize the data layer to common interfaces that Auth.js can understand. | ||
}): Awaitable<VerificationToken | null> | ||
/** | ||
* Get account by provider account id and provider. | ||
* | ||
* If an account is not found, the adapter must return `null`. | ||
*/ | ||
getAccount?( | ||
providerAccountId: AdapterAccount["providerAccountId"], provider: AdapterAccount["provider"] | ||
): Awaitable<AdapterAccount | null> | ||
/** | ||
* Returns an authenticator from its credentialID. | ||
* | ||
* If an authenticator is not found, the adapter must return `null`. | ||
*/ | ||
getAuthenticator?( | ||
credentialID: AdapterAuthenticator['credentialID'] | ||
): Awaitable<AdapterAuthenticator | null> | ||
/** | ||
* Create a new authenticator. | ||
* | ||
* If the creation fails, the adapter must throw an error. | ||
*/ | ||
createAuthenticator?( | ||
authenticator: AdapterAuthenticator | ||
): Awaitable<AdapterAuthenticator> | ||
/** | ||
* Returns all authenticators from a user. | ||
* | ||
* If a user is not found, the adapter should still return an empty array. | ||
* If the retrieval fails for some other reason, the adapter must throw an error. | ||
*/ | ||
listAuthenticatorsByUserId?( | ||
userId: AdapterAuthenticator['userId'] | ||
): Awaitable<AdapterAuthenticator[]> | ||
/** | ||
* Updates an authenticator's counter. | ||
* | ||
* If the update fails, the adapter must throw an error. | ||
*/ | ||
updateAuthenticatorCounter?( | ||
credentialID: AdapterAuthenticator['credentialID'], | ||
newCounter: AdapterAuthenticator['counter'] | ||
): Awaitable<AdapterAuthenticator> | ||
} | ||
@@ -383,0 +435,0 @@ |
@@ -31,2 +31,7 @@ type ErrorOptions = Error | Record<string, unknown> | ||
| "MissingCSRF" | ||
| "AccountNotLinked" | ||
| "DuplicateConditionalUI" | ||
| "MissingWebAuthnAutocomplete" | ||
| "WebAuthnVerificationError" | ||
| "ExperimentalFeatureNotEnabled" | ||
@@ -389,3 +394,3 @@ /** | ||
/** Thrown when the callback endpoint was incorrectly called without a provider. */ | ||
/** Thrown when an endpoint was incorrectly called without a provider, or with an unsupported provider. */ | ||
export class InvalidProvider extends AuthError { | ||
@@ -432,1 +437,42 @@ static type = "InvalidProvider" | ||
} | ||
/** | ||
* Thrown when multiple providers have `enableConditionalUI` set to `true`. | ||
* Only one provider can have this option enabled at a time. | ||
*/ | ||
export class DuplicateConditionalUI extends AuthError { | ||
static type = "DuplicateConditionalUI" | ||
} | ||
/** | ||
* Thrown when a WebAuthn provider has `enableConditionalUI` set to `true` but no formField has `webauthn` in its autocomplete param. | ||
* | ||
* The `webauthn` autocomplete param is required for conditional UI to work. | ||
*/ | ||
export class MissingWebAuthnAutocomplete extends AuthError { | ||
static type = "MissingWebAuthnAutocomplete" | ||
} | ||
/** | ||
* Thrown when a WebAuthn provider fails to verify a client response. | ||
*/ | ||
export class WebAuthnVerificationError extends AuthError { | ||
static type = "WebAuthnVerificationError" | ||
} | ||
/** | ||
* Thrown when an Email address is already associated with an account | ||
* but the user is trying an account that is not linked to it. | ||
* | ||
* For security reasons, Auth.js does not automatically link accounts to existing accounts if the user is not signed in. | ||
*/ | ||
export class AccountNotLinked extends SignInError { | ||
static type = "AccountNotLinked" | ||
} | ||
/** | ||
* Thrown when an experimental feature is used but not enabled. | ||
*/ | ||
export class ExperimentalFeatureNotEnabled extends AuthError { | ||
static type = "ExperimentalFeatureNotEnabled" | ||
} |
@@ -425,3 +425,10 @@ /** | ||
*/ | ||
experimental?: Record<string, boolean> | ||
experimental?: { | ||
/** | ||
* Enable WebAuthn support. | ||
* | ||
* @default false | ||
*/ | ||
enableWebAuthn?: boolean | ||
} | ||
/** | ||
@@ -428,0 +435,0 @@ * The base path of the Auth.js API endpoints. |
@@ -1,2 +0,2 @@ | ||
import { OAuthAccountNotLinked } from "../../../errors.js" | ||
import { AccountNotLinked, OAuthAccountNotLinked } from "../../../errors.js" | ||
import { fromDate } from "../../utils/date.js" | ||
@@ -35,3 +35,3 @@ | ||
throw new Error("Missing or invalid provider account") | ||
if (!["email", "oauth", "oidc"].includes(_account.type)) | ||
if (!["email", "oauth", "oidc", "webauthn"].includes(_account.type)) | ||
throw new Error("Provider not supported") | ||
@@ -129,2 +129,87 @@ | ||
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 } | ||
} | ||
} | ||
@@ -131,0 +216,0 @@ |
@@ -19,7 +19,10 @@ // TODO: Make this file smaller | ||
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" | ||
@@ -368,2 +371,120 @@ /** Handle callbacks from login services */ | ||
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 } | ||
} | ||
@@ -370,0 +491,0 @@ |
@@ -10,5 +10,7 @@ import * as jose from "jose" | ||
RequestInternal, | ||
User, | ||
} from "../../../../types.js" | ||
import type { Cookie } from "../../../utils/cookie.js" | ||
import type { OAuthConfigInternal } from "../../../../providers/oauth.js" | ||
import type { WebAuthnProviderType } from "../../../../providers/webauthn.js" | ||
@@ -24,3 +26,3 @@ interface CheckPayload { | ||
maxAge: number, | ||
options: InternalOptions<"oauth" | "oidc">, | ||
options: InternalOptions<"oauth" | "oidc" | WebAuthnProviderType>, | ||
data?: any | ||
@@ -267,1 +269,50 @@ ): Promise<Cookie> { | ||
} | ||
const WEBAUTHN_CHALLENGE_MAX_AGE = 60 * 15 // 15 minutes in seconds | ||
type WebAuthnChallengeCookie = { challenge: string; registerData?: User } | ||
export const webauthnChallenge = { | ||
async create(options: InternalOptions<WebAuthnProviderType>, challenge: string, registerData?: User) { | ||
const maxAge = WEBAUTHN_CHALLENGE_MAX_AGE | ||
const data: WebAuthnChallengeCookie = { challenge, registerData } | ||
const cookie = await signCookie( | ||
"webauthnChallenge", | ||
JSON.stringify(data), | ||
maxAge, | ||
options | ||
) | ||
return { cookie } | ||
}, | ||
/** | ||
* Returns challenge if present, | ||
*/ | ||
async use( | ||
options: InternalOptions<WebAuthnProviderType>, | ||
cookies: RequestInternal["cookies"], | ||
resCookies: Cookie[], | ||
): Promise<WebAuthnChallengeCookie> { | ||
const challenge = cookies?.[options.cookies.webauthnChallenge.name] | ||
if (!challenge) | ||
throw new InvalidCheck("Challenge cookie missing.") | ||
const value = await decode<CheckPayload>({ | ||
...options.jwt, | ||
token: challenge, | ||
salt: options.cookies.webauthnChallenge.name, | ||
}) | ||
if (!value?.value) | ||
throw new InvalidCheck("Challenge value could not be parsed.") | ||
// Clear the pkce code verifier cookie after use | ||
const cookie = { | ||
name: options.cookies.webauthnChallenge.name, | ||
value: "", | ||
options: { ...options.cookies.webauthnChallenge.options, maxAge: 0 }, | ||
} | ||
resCookies.push(cookie) | ||
return JSON.parse(value.value) as WebAuthnChallengeCookie | ||
}, | ||
} |
@@ -5,1 +5,2 @@ export { callback } from "./callback/index.js" | ||
export { signOut } from "./signout.js" | ||
export { webAuthnOptions } from "./webauthn-options.js" |
@@ -56,2 +56,9 @@ import { UnknownAction } from "../errors.js" | ||
return render.verifyRequest() | ||
case "webauthn-options": | ||
return await actions.webAuthnOptions( | ||
request, | ||
options, | ||
sessionStore, | ||
cookies, | ||
) | ||
default: | ||
@@ -58,0 +65,0 @@ } |
@@ -17,4 +17,5 @@ import { renderToString } from "preact-render-to-string" | ||
import type { Cookie } from "../utils/cookie.js" | ||
import { getSimpleWebAuthnBrowserScriptTag } from "../../providers/webauthn.js" | ||
function send({ html, title, status, cookies, theme }: any): ResponseInternal { | ||
function send({ html, title, status, cookies, theme, headTags }: any): ResponseInternal { | ||
return { | ||
@@ -24,5 +25,5 @@ cookies, | ||
headers: { "Content-Type": "text/html" }, | ||
body: `<!DOCTYPE html><html lang="en"><head><meta charset="UTF-8"><meta http-equiv="X-UA-Compatible" content="IE=edge"><meta name="viewport" content="width=device-width, initial-scale=1.0"><style>${css}</style><title>${title}</title></head><body class="__next-auth-theme-${ | ||
theme?.colorScheme ?? "auto" | ||
}"><div class="page">${renderToString(html)}</div></body></html>`, | ||
body: `<!DOCTYPE html><html lang="en"><head><meta charset="UTF-8"><meta http-equiv="X-UA-Compatible" content="IE=edge"><meta name="viewport" content="width=device-width, initial-scale=1.0"><style>${css}</style><title>${title}</title>${ | ||
headTags ?? "" | ||
}</head><body class="__next-auth-theme-${theme?.colorScheme ?? "auto"}"><div class="page">${renderToString(html)}</div></body></html>`, | ||
} | ||
@@ -46,3 +47,3 @@ } | ||
export default function renderPage(params: RenderPageParams) { | ||
const { url, theme, query, cookies, pages } = params | ||
const { url, theme, query, cookies, pages, providers } = params | ||
@@ -87,2 +88,13 @@ return { | ||
} | ||
// If we have a webauthn provider with conditional UI and | ||
// a simpleWebAuthnBrowserScript is defined, we need to | ||
// render the script in the page. | ||
const webauthnProvider = providers?.find( | ||
(p): p is InternalProvider<"webauthn"> => p.type === "webauthn" && p.enableConditionalUI | ||
) | ||
const simpleWebAuthnBrowserScript = webauthnProvider ? | ||
getSimpleWebAuthnBrowserScriptTag(webauthnProvider) : | ||
undefined | ||
return send({ | ||
@@ -100,2 +112,4 @@ cookies, | ||
(provider.type === "credentials" && provider.credentials) || | ||
// Only render webauthn type provider if formFields are defined | ||
(provider.type === "webauthn" && provider.formFields) || | ||
// Don't render other provider types | ||
@@ -110,2 +124,3 @@ false | ||
title: "Sign In", | ||
headTags: simpleWebAuthnBrowserScript, | ||
}) | ||
@@ -112,0 +127,0 @@ }, |
@@ -54,3 +54,3 @@ // Generated by `pnpm css` | ||
} | ||
button:hover, a.button:hover { | ||
:is(button,a.button):hover { | ||
background-color: var( | ||
@@ -180,3 +180,3 @@ --provider-dark-bg-hover, | ||
button:hover, a.button:hover { | ||
:is(button,a.button):hover { | ||
background-color: var(--provider-bg-hover, var(--color-background-hover)); | ||
@@ -186,7 +186,7 @@ cursor: pointer; | ||
button:active, a.button:active { | ||
:is(button,a.button):active { | ||
cursor: pointer; | ||
} | ||
button #provider-logo, a.button #provider-logo { | ||
:is(button,a.button) #provider-logo { | ||
width: 25px; | ||
@@ -196,3 +196,3 @@ display: block; | ||
button #provider-logo-dark, a.button #provider-logo-dark { | ||
:is(button,a.button) #provider-logo-dark { | ||
display: none; | ||
@@ -199,0 +199,0 @@ } |
@@ -12,2 +12,3 @@ import type { AuthAction } from "../../types.js" | ||
"error", | ||
"webauthn-options", | ||
] | ||
@@ -14,0 +15,0 @@ |
import { defaultCookies } from "./cookie.js" | ||
import { | ||
AuthError, | ||
DuplicateConditionalUI, | ||
ExperimentalFeatureNotEnabled, | ||
InvalidCallbackUrl, | ||
@@ -9,2 +12,3 @@ InvalidEndpoints, | ||
MissingSecret, | ||
MissingWebAuthnAutocomplete, | ||
UnsupportedStrategy, | ||
@@ -14,4 +18,5 @@ UntrustedHost, | ||
import type { AuthConfig, RequestInternal } from "../../types.js" | ||
import type { AuthConfig, RequestInternal, SemverString } from "../../types.js" | ||
import type { WarningCode } from "./logger.js" | ||
import { Adapter } from "../../adapters.js" | ||
@@ -39,6 +44,11 @@ type ConfigError = | ||
function isSemverString(version: string): version is SemverString { | ||
return /^v\d+(?:\.\d+){0,2}$/.test(version) | ||
} | ||
let hasCredentials = false | ||
let hasEmail = false | ||
let hasWebAuthn = false | ||
const emailMethods = [ | ||
const emailMethods: (keyof Adapter)[] = [ | ||
"createVerificationToken", | ||
@@ -49,3 +59,3 @@ "useVerificationToken", | ||
const sessionMethods = [ | ||
const sessionMethods: (keyof Adapter)[] = [ | ||
"createUser", | ||
@@ -63,2 +73,13 @@ "getUser", | ||
const webauthnMethods: (keyof Adapter)[] = [ | ||
"createUser", | ||
"getUser", | ||
"linkAccount", | ||
"getAccount", | ||
"getAuthenticator", | ||
"createAuthenticator", | ||
"listAuthenticatorsByUserId", | ||
"updateAuthenticatorCounter", | ||
] | ||
/** | ||
@@ -109,2 +130,5 @@ * Verify that the user configured Auth.js correctly. | ||
// Keep track of webauthn providers that use conditional UI | ||
let hasConditionalUIProvider = false | ||
for (const p of options.providers) { | ||
@@ -132,2 +156,32 @@ const provider = typeof p === "function" ? p() : p | ||
else if (provider.type === "email") hasEmail = true | ||
else if (provider.type === "webauthn") { | ||
hasWebAuthn = true | ||
// Validate simpleWebAuthnBrowserVersion | ||
if (provider.simpleWebAuthnBrowserVersion && !isSemverString(provider.simpleWebAuthnBrowserVersion)) { | ||
return new AuthError( | ||
`Invalid provider config for "${provider.id}": simpleWebAuthnBrowserVersion "${provider.simpleWebAuthnBrowserVersion}" must be a valid semver string.` | ||
) | ||
} | ||
if (provider.enableConditionalUI) { | ||
// Make sure only one webauthn provider has "enableConditionalUI" set to true | ||
if (hasConditionalUIProvider) { | ||
return new DuplicateConditionalUI( | ||
`Multiple webauthn providers have 'enableConditionalUI' set to True. Only one provider can have this option enabled at a time.` | ||
) | ||
} | ||
hasConditionalUIProvider = true | ||
// Make sure at least one formField has "webauthn" in its autocomplete param | ||
const hasWebauthnFormField = Object.values( | ||
provider.formFields | ||
).some((f) => f.autocomplete && f.autocomplete.toString().indexOf("webauthn") > -1) | ||
if (!hasWebauthnFormField) { | ||
return new MissingWebAuthnAutocomplete( | ||
`Provider "${provider.id}" has 'enableConditionalUI' set to True, but none of its formFields have 'webauthn' in their autocomplete param.` | ||
) | ||
} | ||
} | ||
} | ||
} | ||
@@ -158,21 +212,30 @@ | ||
const { adapter, session } = options | ||
if ( | ||
hasEmail || | ||
session?.strategy === "database" || | ||
(!session?.strategy && adapter) | ||
) { | ||
let methods: string[] | ||
let requiredMethods: (keyof Adapter)[] = [] | ||
if (hasEmail || session?.strategy === "database" || (!session?.strategy && adapter)) { | ||
if (hasEmail) { | ||
if (!adapter) | ||
return new MissingAdapter("Email login requires an adapter.") | ||
methods = emailMethods | ||
if (!adapter) return new MissingAdapter("Email login requires an adapter.") | ||
requiredMethods.push(...emailMethods) | ||
} else { | ||
if (!adapter) | ||
return new MissingAdapter("Database session requires an adapter.") | ||
methods = sessionMethods | ||
if (!adapter) return new MissingAdapter("Database session requires an adapter.") | ||
requiredMethods.push(...sessionMethods) | ||
} | ||
} | ||
const missing = methods.filter((m) => !adapter[m as keyof typeof adapter]) | ||
if (hasWebAuthn) { | ||
// Log experimental warning | ||
if (options.experimental?.enableWebAuthn) { | ||
warnings.push("experimental-webauthn") | ||
} else { | ||
return new ExperimentalFeatureNotEnabled("WebAuthn is an experimental feature. To enable it, set `experimental.enableWebAuthn` to `true` in your config.") | ||
} | ||
if (!adapter) return new MissingAdapter("WebAuthn requires an adapter.") | ||
requiredMethods.push(...webauthnMethods) | ||
} | ||
if (adapter) { | ||
const missing = requiredMethods.filter((m) => !(m in adapter)) | ||
if (missing.length) { | ||
@@ -179,0 +242,0 @@ return new MissingAdapterMethods( |
@@ -120,2 +120,12 @@ import type { | ||
}, | ||
webauthnChallenge: { | ||
name: `${cookiePrefix}authjs.challenge`, | ||
options: { | ||
httpOnly: true, | ||
sameSite: "lax", | ||
path: "/", | ||
secure: useSecureCookies, | ||
maxAge: 60 * 15, // 15 minutes in seconds | ||
}, | ||
}, | ||
} as const satisfies CookiesOptions | ||
@@ -122,0 +132,0 @@ } |
import { AuthError } from "../../errors.js" | ||
export type WarningCode = "debug-enabled" | "csrf-disabled" | ||
export type WarningCode = "debug-enabled" | "csrf-disabled" | "experimental-webauthn" | ||
@@ -5,0 +5,0 @@ /** |
@@ -137,3 +137,3 @@ import { parse as parseCookie, serialize } from "cookie" | ||
if (providerId && !["signin", "callback"].includes(action)) | ||
if (providerId && !["signin", "callback", "webauthn-options"].includes(action)) | ||
throw new UnknownAction(`Cannot parse action at ${pathname}`) | ||
@@ -140,0 +140,0 @@ |
@@ -15,2 +15,3 @@ import { Profile } from "../types.js" | ||
} from "./oauth.js" | ||
import { WebAuthnConfig, WebAuthnProviderType } from "./webauthn.js" | ||
@@ -29,3 +30,3 @@ export * from "./credentials.js" | ||
*/ | ||
export type ProviderType = "oidc" | "oauth" | "email" | "credentials" | ||
export type ProviderType = "oidc" | "oauth" | "email" | "credentials" | WebAuthnProviderType | ||
@@ -68,7 +69,7 @@ /** Shared across all {@link ProviderType} */ | ||
export type Provider<P extends Profile = any> = ( | ||
| ((OIDCConfig<P> | OAuth2Config<P> | EmailConfig | CredentialsConfig) & | ||
| ((OIDCConfig<P> | OAuth2Config<P> | EmailConfig | CredentialsConfig | WebAuthnConfig) & | ||
InternalProviderOptions) | ||
| (( | ||
...args: any | ||
) => (OAuth2Config<P> | OIDCConfig<P> | EmailConfig | CredentialsConfig) & | ||
) => (OAuth2Config<P> | OIDCConfig<P> | EmailConfig | CredentialsConfig | WebAuthnConfig) & | ||
InternalProviderOptions) | ||
@@ -83,3 +84,4 @@ ) & | ||
Record<CredentialsProviderType, typeof CredentialsProvider> & | ||
Record<EmailProviderType, typeof EmailProvider> | ||
Record<EmailProviderType, typeof EmailProvider> & | ||
Record<WebAuthnProviderType, (config: Partial<WebAuthnConfig>) => WebAuthnConfig> | ||
@@ -97,2 +99,2 @@ export type AppProviders = Array< | ||
export type BuiltInProviderType = RedirectableProviderType | OAuthProviderType | ||
export type BuiltInProviderType = RedirectableProviderType | OAuthProviderType | WebAuthnProviderType |
@@ -57,2 +57,3 @@ | ||
| "passage" | ||
| "passkey" | ||
| "patreon" | ||
@@ -75,2 +76,3 @@ | "pinterest" | ||
| "vk" | ||
| "webauthn" | ||
| "wikimedia" | ||
@@ -77,0 +79,0 @@ | "wordpress" |
@@ -76,2 +76,6 @@ /** | ||
} from "./providers/index.js" | ||
import { | ||
WebAuthnConfig, | ||
WebAuthnProviderType, | ||
} from "./providers/webauthn" | ||
@@ -81,3 +85,6 @@ export type { AuthConfig } from "./index.js" | ||
export type Awaitable<T> = T | PromiseLike<T> | ||
export type Awaited<T> = T extends Promise<infer U> ? U : T | ||
export type SemverString = `v${number}` | `v${number}.${number}` | `v${number}.${number}.${number}` | ||
/** | ||
@@ -349,2 +356,3 @@ * Change the theme of the built-in pages. | ||
nonce: Partial<CookieOption> | ||
webauthnChallenge: Partial<CookieOption> | ||
} | ||
@@ -448,3 +456,3 @@ | ||
/** The active session of the logged in user. */ | ||
export interface Session extends DefaultSession {} | ||
export interface Session extends DefaultSession { } | ||
@@ -469,12 +477,14 @@ /** | ||
: T extends "oidc" | ||
? OIDCConfigInternal<any> | ||
: T extends "email" | ||
? EmailConfig | ||
: T extends "credentials" | ||
? CredentialsConfig | ||
: never) & { | ||
signinUrl: string | ||
/** @example `"https://example.com/api/auth/callback/id"` */ | ||
callbackUrl: string | ||
} | ||
? OIDCConfigInternal<any> | ||
: T extends "email" | ||
? EmailConfig | ||
: T extends "credentials" | ||
? CredentialsConfig | ||
: T extends WebAuthnProviderType | ||
? WebAuthnConfig | ||
: never) & { | ||
signinUrl: string | ||
/** @example `"https://example.com/api/auth/callback/id"` */ | ||
callbackUrl: string | ||
} | ||
@@ -515,2 +525,4 @@ export interface PublicProvider { | ||
* - **`"verify-request"`**: Renders the built-in verification request page. | ||
* - **`"webauthn-options"`**: | ||
* - **`GET`**: Returns the options for the WebAuthn authentication and registration flows. | ||
*/ | ||
@@ -526,2 +538,3 @@ export type AuthAction = | ||
| "verify-request" | ||
| "webauthn-options" | ||
@@ -552,2 +565,44 @@ /** @internal */ | ||
/** | ||
* A webauthn authenticator. | ||
* Represents an entity capable of authenticating the account it references, | ||
* and contains the auhtenticator's credentials and related information. | ||
* | ||
* @see https://www.w3.org/TR/webauthn/#authenticator | ||
*/ | ||
export interface Authenticator { | ||
/** | ||
* ID of the user this authenticator belongs to. | ||
*/ | ||
userId?: string | ||
/** | ||
* The provider account ID connected to the authenticator. | ||
*/ | ||
providerAccountId: string | ||
/** | ||
* Number of times the authenticator has been used. | ||
*/ | ||
counter: number | ||
/** | ||
* Whether the client authenticator backed up the credential. | ||
*/ | ||
credentialBackedUp: boolean | ||
/** | ||
* Base64 encoded credential ID. | ||
*/ | ||
credentialID: string | ||
/** | ||
* Base64 encoded credential public key. | ||
*/ | ||
credentialPublicKey: string | ||
/** | ||
* Concatenated transport flags. | ||
*/ | ||
transports?: string | ||
/** | ||
* Device type of the authenticator. | ||
*/ | ||
credentialDeviceType: string | ||
} | ||
/** @internal */ | ||
@@ -582,4 +637,4 @@ export interface InternalOptions<TProviderType = ProviderType> { | ||
isOnRedirectProxy: boolean | ||
experimental: Record<string, boolean> | ||
experimental: NonNullable<AuthConfig['experimental']> | ||
basePath: string | ||
} |
@@ -67,2 +67,4 @@ /** | ||
export type Awaitable<T> = T | PromiseLike<T>; | ||
export type Awaited<T> = T extends Promise<infer U> ? U : T; | ||
export type SemverString = `v${number}` | `v${number}.${number}` | `v${number}.${number}.${number}`; | ||
/** | ||
@@ -324,2 +326,3 @@ * Change the theme of the built-in pages. | ||
nonce: Partial<CookieOption>; | ||
webauthnChallenge: Partial<CookieOption>; | ||
} | ||
@@ -458,4 +461,6 @@ /** | ||
* - **`"verify-request"`**: Renders the built-in verification request page. | ||
* - **`"webauthn-options"`**: | ||
* - **`GET`**: Returns the options for the WebAuthn authentication and registration flows. | ||
*/ | ||
export type AuthAction = "callback" | "csrf" | "error" | "providers" | "session" | "signin" | "signout" | "verify-request"; | ||
export type AuthAction = "callback" | "csrf" | "error" | "providers" | "session" | "signin" | "signout" | "verify-request" | "webauthn-options"; | ||
export interface ResponseInternal<Body extends string | Record<string, any> | any[] | null = any> { | ||
@@ -468,2 +473,43 @@ status?: number; | ||
} | ||
/** | ||
* A webauthn authenticator. | ||
* Represents an entity capable of authenticating the account it references, | ||
* and contains the auhtenticator's credentials and related information. | ||
* | ||
* @see https://www.w3.org/TR/webauthn/#authenticator | ||
*/ | ||
export interface Authenticator { | ||
/** | ||
* ID of the user this authenticator belongs to. | ||
*/ | ||
userId?: string; | ||
/** | ||
* The provider account ID connected to the authenticator. | ||
*/ | ||
providerAccountId: string; | ||
/** | ||
* Number of times the authenticator has been used. | ||
*/ | ||
counter: number; | ||
/** | ||
* Whether the client authenticator backed up the credential. | ||
*/ | ||
credentialBackedUp: boolean; | ||
/** | ||
* Base64 encoded credential ID. | ||
*/ | ||
credentialID: string; | ||
/** | ||
* Base64 encoded credential public key. | ||
*/ | ||
credentialPublicKey: string; | ||
/** | ||
* Concatenated transport flags. | ||
*/ | ||
transports?: string; | ||
/** | ||
* Device type of the authenticator. | ||
*/ | ||
credentialDeviceType: string; | ||
} | ||
//# sourceMappingURL=types.d.ts.map |
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
License Policy Violation
LicenseThis package is not allowed per your license policy. Review the package's license to ensure compliance.
Found 1 instance in 1 package
License Policy Violation
LicenseThis package is not allowed per your license policy. Review the package's license to ensure compliance.
Found 1 instance in 1 package
1520801
492
38210
9
7
18