diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index aef7a3b6ca..748bf3e2de 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -43,11 +43,13 @@ import { ErrorPageLoop, isClientError, } from "./errors.js" -import { AuthInternal, raw, skipCSRFCheck } from "./lib/index.js" +import { AuthInternal } from "./lib/index.js" import { setEnvDefaults, createActionURL } from "./lib/utils/env.js" import renderPage from "./lib/pages/index.js" import { setLogger, type LoggerInstance } from "./lib/utils/logger.js" import { toInternalRequest, toResponse } from "./lib/utils/web.js" +import { isAuthAction } from "./lib/utils/actions.js" +import { raw, skipCSRFCheck, customFetch } from "./lib/symbols.js" import type { Adapter, AdapterSession, AdapterUser } from "./adapters.js" import type { @@ -64,11 +66,16 @@ import type { User, } from "./types.js" import type { CredentialInput, Provider } from "./providers/index.js" -import { JWT, JWTOptions } from "./jwt.js" -import { isAuthAction } from "./lib/utils/actions.js" +import type { JWT, JWTOptions } from "./jwt.js" -export { customFetch } from "./lib/symbols.js" -export { skipCSRFCheck, raw, setEnvDefaults, createActionURL, isAuthAction } +export { + setEnvDefaults, + createActionURL, + isAuthAction, + customFetch, + raw, + skipCSRFCheck, +} export async function Auth( request: Request, @@ -213,54 +220,76 @@ export async function Auth( * * @see [Initialization](https://authjs.dev/reference/core/types#authconfig) */ -export interface AuthConfig { +export interface AuthConfig + extends IntegrationAuthConfig, + DeprecatedAuthConfig { /** - * List of authentication providers for signing in + * The only required option. + * + * The list of authentication providers for signing in * (e.g. Google, Facebook, Twitter, GitHub, Email, etc) in any order. * This can be one of the built-in providers or an object with a custom provider. - * - * @default [] */ providers: Provider[] /** - * A random string used to hash tokens, sign cookies and generate cryptographic keys. + * A cryptographically random string or list of strings that is used to hash tokens, + * seal cookies (JWT encryption by default) and generate other cryptographic keys. * - * To generate a random string, you can use the Auth.js CLI: `npx auth secret` + * You can generate a random string with our CLI: `npx auth secret` or use a tool like `openssl`. + * + * If you pass an array of secrets, we will iterate over them from first-to-last, trying to unseal JWT encrypted cookies. * * @note - * You can also pass an array of secrets, in which case the first secret that successfully - * decrypts the JWT will be used. This is useful for rotating secrets without invalidating existing sessions. - * The newer secret should be added to the start of the array, which will be used for all new sessions. + * This is useful for rotating secrets without invalidating existing sessions. + * The newer secret should be added to the start of the array. This will be used for all new sessions. + * + * We support inferring up to 4 secrets from the environment variables `AUTH_SECRET`, `AUTH_SECRET_1`, `AUTH_SECRET_2`, `AUTH_SECRET_3`, + * in which case, this option is optional. * */ secret?: string | string[] - /** - * Configure your session like if you want to use JWT or a database, - * how long until an idle session expires, or to throttle write operations in case you are using a database. - */ + /** Configure how you want to persist your session, how often it should be updated, or in what format it should be saved. */ session?: { /** * Choose how you want to save the user session. - * The default is `"jwt"`, an encrypted JWT (JWE) in the session cookie. * - * If you use an `adapter` however, we default it to `"database"` instead. - * You can still force a JWT session by explicitly defining `"jwt"`. + * The default is `"cookie"` (Previously called "jwt", but same behavior). This saves the session information as an [encrypted JWT](https://datatracker.ietf.org/doc/html/rfc7516) in cookies. + * + * @note Even if the persisted information would exceed the 4kb cookie limit most browsers impose, Auth.js + * will chunk the cookie into multiple cookies to avoid this limitation. * - * When using `"database"`, the session cookie will only contain a `sessionToken` value, - * which is used to look up the session in the database. + * If you use an {@link AuthConfig.adapter} however, the default is set to `"database"` instead. * - * [Documentation](https://authjs.dev/reference/core#authconfig#session) | [Adapter](https://authjs.dev/reference/core#authconfig#adapter) | [About JSON Web Tokens](https://authjs.dev/concepts/session-strategies#jwt-session) + * Note, that you can still force a JWT session by explicitly defining `"jwt"`. + * + * Learn more about the different [session strategies](https://authjs.dev/concepts/session-strategies), + * their advantages and disadvantages. */ - strategy?: "jwt" | "database" + strategy?: "cookie" | "database" | "jwt" /** - * Relative time from now in seconds when to expire the session + * Either a relative time in seconds, or an absolute `Date` when to expire the session. + * + * - If a relative time is set, the session expiry is updated when the session is accessed, + * but at most at the rate of `updateAge` value. + * + * @note This strikes a balance between updating the session too often + * or letting it expire mid-action while the user is interacting with the site. + * + * - If an absolute `Date` is set, the session will expire at that time, regardless of activity. + * + * @note Currently, there is no way to expire a session when the browser is closed, as most browsers + * keep running in the background and keep the session alive indefinitely, which would give a false sense of security, + * as the session would still be valid if the browser is reopened. + * For this reason, we recommend: + * 1. setting a short `maxAge` + * 2. using a database session strategy that you can revoke server-side + * 3. set an absolute `Date` for the session expiry * * @default 2592000 // 30 days */ - maxAge?: number + maxAge?: number // TODO: | Date /** - * How often the session should be updated in seconds. - * If set to `0`, session is updated every time. + * How often the session should be updated in seconds. If set to `0`, the session is updated every time. * * @default 86400 // 1 day */ @@ -270,20 +299,33 @@ export interface AuthConfig { * By default, a random UUID or string is generated depending on the Node.js version. * However, you can specify your own custom string (such as CUID) to be used. * + * @note this is not equivalent to the ID of the session in the database, to avoid leaking information, + * eg. if the database creates predictable IDs. + * * @default `randomUUID` or `randomBytes.toHex` depending on the Node.js version */ generateSessionToken?: () => string + /** + * Seals the session payload in the cookie, to obscure the data from the client. + * + * By default, the cookie is sealed using an encrypted JWT. It uses the _A256CBC-HS512_ algorithm ({@link https://www.rfc-editor.org/rfc/rfc7518.html#section-5.2.5 JWE}). + * {@link AuthConfig.session.secret} is used to derive a suitable encryption key. + */ + seal?: JWTOptions["encode"] + /** + * Unseals the session payload from the cookie, to read the data on the server. + * + * By default, the cookie is sealed using an encrypted JWT. It uses the _A256CBC-HS512_ algorithm ({@link https://www.rfc-editor.org/rfc/rfc7518.html#section-5.2.5 JWE}). + * {@link AuthConfig.session.secret} is used to derive the encryption key. + * + * If you passed an array of secrets, we will iterate over them from first-to-last, trying to unseal the data. + */ + unseal?: JWTOptions["decode"] } - /** - * JSON Web Tokens are enabled by default if you have not specified an {@link AuthConfig.adapter}. - * JSON Web Tokens are encrypted (JWE) by default. We recommend you keep this behaviour. - */ - jwt?: Partial /** * Specify URLs to be used if you want to create custom sign in, sign out and error pages. * Pages specified will override the corresponding built-in page. * - * @default {} * @example * * ```ts @@ -296,7 +338,7 @@ export interface AuthConfig { * } * ``` */ - pages?: Partial + pages?: PagesOptions /** * Callbacks are asynchronous functions you can use to control what happens when an action is performed. * Callbacks are *extremely powerful*, especially in scenarios involving JSON Web Tokens @@ -531,16 +573,17 @@ export interface AuthConfig { /** You can use the adapter option to pass in your database adapter. */ adapter?: Adapter /** - * Set debug to true to enable debug messages for authentication and database operations. + * Set the log level for the built-in logger. * - * - ⚠ If you added a custom {@link AuthConfig.logger}, this setting is ignored. + * If any of the log levels are overriden in {@link AuthConfig.logger}, + * this setting is ignored for that level. * - * @default false + * @default "error" */ - debug?: boolean + logLevel?: "verbose" | "warn" | "error" | "silent" /** * Override any of the logger levels (`undefined` levels will use the built-in logger), - * and intercept logs in NextAuth. You can use this option to send NextAuth logs to a third-party logging service. + * and intercept logs in Auth.js. You can use this option to send Auth.js logs to a third-party logging service. * * @example * @@ -599,17 +642,21 @@ export interface AuthConfig { */ cookies?: Partial /** - * Auth.js relies on the incoming request's `host` header to function correctly. For this reason this property needs to be set to `true`. + * Auth.js relies on the incoming request's `host` header to function correctly. For this reason this property needs to be set to `true` explicitly. * * Make sure that your deployment platform sets the `host` header safely. * - * :::note - * Official Auth.js-based libraries will attempt to set this value automatically for some deployment platforms (eg.: Vercel) that are known to set the `host` header safely. - * ::: + * @note + * Auth.js will attempt to set this value automatically for some cases, eg.: if it detects a trusted platform's environment variable, + * or if the host value can be inferred from the environment, instead of the incoming request. + * + * The following conditions will enable this automatically: + * + * ```ts + * AUTH_URL ?? AUTH_TRUST_HOST ?? VERCEL ?? CF_PAGES ?? NODE_ENV !== "production" + * ``` */ trustHost?: boolean - skipCSRFCheck?: typeof skipCSRFCheck - raw?: typeof raw /** * When set, during an OAuth sign-in flow, * the `redirect_uri` of the authorization request @@ -642,25 +689,64 @@ export interface AuthConfig { * See also: [Guide: Securing a Preview Deployment](https://authjs.dev/getting-started/deployment#securing-a-preview-deployment) */ redirectProxyUrl?: string - /** - * Use this option to enable experimental features. - * When enabled, it will print a warning message to the console. - * @note Experimental features are not guaranteed to be stable and may change or be removed without notice. Please use with caution. - * @default {} + * Enable/disable experimental features. + * + * @note Experimental features are not guaranteed to be stable and may change or be removed without notice. */ - experimental?: { - /** - * Enable WebAuthn support. - * - * @default false - */ - enableWebAuthn?: boolean - } + experimental?: ExperimentalOptions /** * The base path of the Auth.js API endpoints. * - * @default "/api/auth" in "next-auth"; "/auth" with all other frameworks + * @default `"/api/auth"` in "next-auth" (for historical reasons only); `"/auth"` for all other frameworks */ basePath?: string } + +interface ExperimentalOptions { + /** + * Enable [WebAuthn](https://authjs.dev/getting-started/authentication/webauthn) support. + * + * @default false + */ + enableWebAuthn?: boolean +} + +interface DeprecatedAuthConfig { + /** + * Set debug to true to enable debug messages for authentication and database operations. + * + * - ⚠ If you added a custom {@link AuthConfig.logger}, this setting is ignored. + * + * @default false + * @deprecated Use `logLevel: "verbose"` instead. + */ + debug?: boolean + /** + * JSON Web Tokens are enabled by default if you have not specified an {@link AuthConfig.adapter}. + * JSON Web Tokens are encrypted (JWE) by default. We recommend you keep this behaviour. + * + * @deprecated + */ + jwt?: Partial +} + +/** + * These options are meant for integrators who would like to use `@auth/core` as the base for their library. + * + * If you are a developer, you likely do not need these options. + */ +export interface IntegrationAuthConfig { + /** + * Auth.js ships its own CSRF protection. You can disable this, if your framework has built-in protection. + * Make sure your framework covers both server and client-side. + */ + skipCSRFCheck?: typeof skipCSRFCheck + /** + * By default, the `@auth/core` package returns a `Response` object. + * It might be easier though to not needing to re-parse the response if you are creating + * a framework-specific package. This option will make the Auth.js core return + * the internal response object instead. + */ + raw?: typeof raw +} diff --git a/packages/core/src/jwt.ts b/packages/core/src/jwt.ts index fe93e12668..b92f990829 100644 --- a/packages/core/src/jwt.ts +++ b/packages/core/src/jwt.ts @@ -276,8 +276,16 @@ export interface JWTOptions { * @default 30 * 24 * 60 * 60 // 30 days */ maxAge: number - /** Override this method to control the Auth.js issued JWT encoding. */ + /** + * Override this method to control the Auth.js issued JWT encoding. + * + * @deprecated Use {@link AuthConfig.session.seal} instead. + */ encode: (params: JWTEncodeParams) => Awaitable - /** Override this method to control the Auth.js issued JWT decoding. */ + /** + * Override this method to control the Auth.js issued JWT decoding. + * + * @deprecated Use {@link AuthConfig.session.unseal} instead. + */ decode: (params: JWTDecodeParams) => Awaitable } diff --git a/packages/core/src/lib/actions/callback/handle-login.ts b/packages/core/src/lib/actions/callback/handle-login.ts index e053c3ef63..1a6fbf8584 100644 --- a/packages/core/src/lib/actions/callback/handle-login.ts +++ b/packages/core/src/lib/actions/callback/handle-login.ts @@ -6,7 +6,7 @@ import type { AdapterSession, AdapterUser, } from "../../../adapters.js" -import type { Account, InternalOptions, User } from "../../../types.js" +import type { Account, InternalConfig, User } from "../../../types.js" import type { JWT } from "../../../jwt.js" import type { OAuthConfig } from "../../../providers/index.js" import type { SessionToken } from "../../utils/cookie.js" @@ -27,7 +27,7 @@ export async function handleLoginOrRegister( sessionToken: SessionToken, _profile: User | AdapterUser | { email: string }, _account: AdapterAccount | Account | null, - options: InternalOptions + config: InternalConfig ) { // Input validation if (!_account?.providerAccountId || !_account.type) @@ -35,12 +35,7 @@ export async function handleLoginOrRegister( if (!["email", "oauth", "oidc", "webauthn"].includes(_account.type)) throw new Error("Provider not supported") - const { - adapter, - jwt, - events, - session: { strategy: sessionStrategy, generateSessionToken }, - } = options + const { adapter, events, session: sessionConfig } = config // If no adapter is configured then we don't have a database and cannot // persist data; in this mode we just return a dummy session object. @@ -67,13 +62,13 @@ export async function handleLoginOrRegister( let user: AdapterUser | null = null let isNewUser = false - const useJwtSession = sessionStrategy === "jwt" + const useJwtSession = !sessionConfig.isDatabase + const { generateSessionToken, maxAge: sessionMaxAge } = sessionConfig if (sessionToken) { if (useJwtSession) { try { - const salt = options.cookies.sessionToken.name - session = await jwt.decode({ ...jwt, token: sessionToken, salt }) + session = await sessionConfig.unseal(sessionToken) if (session && "sub" in session && session.sub) { user = await getUser(session.sub) } @@ -121,7 +116,7 @@ export async function handleLoginOrRegister( : await createSession({ sessionToken: generateSessionToken(), userId: user.id, - expires: fromDate(options.session.maxAge), + expires: fromDate(sessionMaxAge), }) return { session, user, isNewUser } @@ -153,7 +148,7 @@ export async function handleLoginOrRegister( : await createSession({ sessionToken: generateSessionToken(), userId: userByAccount.id, - expires: fromDate(options.session.maxAge), + expires: fromDate(sessionMaxAge), }) const currentAccount: AdapterAccount = { @@ -212,7 +207,7 @@ export async function handleLoginOrRegister( : await createSession({ sessionToken: generateSessionToken(), userId: user.id, - expires: fromDate(options.session.maxAge), + expires: fromDate(sessionMaxAge), }) const currentAccount: AdapterAccount = { ...account, userId: user.id } @@ -246,12 +241,12 @@ export async function handleLoginOrRegister( : await createSession({ sessionToken: generateSessionToken(), userId: userByAccount.id, - expires: fromDate(options.session.maxAge), + expires: fromDate(sessionMaxAge), }) return { session, user: userByAccount, isNewUser } } else { - const { provider: p } = options as InternalOptions<"oauth" | "oidc"> + const { provider: p } = config as InternalConfig<"oauth" | "oidc"> const { type, provider, providerAccountId, userId, ...tokenSet } = account const defaults = { providerAccountId, provider, type, userId } account = Object.assign(p.account(tokenSet) ?? {}, defaults) @@ -287,7 +282,7 @@ export async function handleLoginOrRegister( ? await getUserByEmail(profile.email) : null if (userByEmail) { - const provider = options.provider as OAuthConfig + const provider = config.provider as OAuthConfig if (provider?.allowDangerousEmailAccountLinking) { // If you trust the oauth provider to correctly verify email addresses, you can opt-in to // account linking even when the user is not signed-in. @@ -326,7 +321,7 @@ export async function handleLoginOrRegister( : await createSession({ sessionToken: generateSessionToken(), userId: user.id, - expires: fromDate(options.session.maxAge), + expires: fromDate(sessionMaxAge), }) return { session, user, isNewUser } diff --git a/packages/core/src/lib/actions/callback/index.ts b/packages/core/src/lib/actions/callback/index.ts index d11048f7cb..769545f4b5 100644 --- a/packages/core/src/lib/actions/callback/index.ts +++ b/packages/core/src/lib/actions/callback/index.ts @@ -17,12 +17,11 @@ import type { AdapterSession } from "../../../adapters.js" import type { Account, Authenticator, - InternalOptions, + InternalConfig, RequestInternal, ResponseInternal, User, } from "../../../types.js" -import type { Cookie, SessionStore } from "../../utils/cookie.js" import { assertInternalOptionsWebAuthn, verifyAuthenticate, @@ -32,11 +31,9 @@ import { /** Handle callbacks from login services */ export async function callback( request: RequestInternal, - options: InternalOptions, - sessionStore: SessionStore, - cookies: Cookie[] + config: InternalConfig ): Promise { - if (!options.provider) + if (!config.provider) throw new InvalidProvider("Callback route called without provider") const { query, body, method, headers } = request const { @@ -45,14 +42,16 @@ export async function callback( url, callbackUrl, pages, - jwt, events, callbacks, - session: { strategy: sessionStrategy, maxAge: sessionMaxAge }, + session: sessionConfig, logger, - } = options + resCookies: cookies, + sessionStore, + } = config - const useJwtSession = sessionStrategy === "jwt" + const useJwtSession = !sessionConfig.isDatabase + const sessionMaxAge = sessionConfig.maxAge try { if (provider.type === "oauth" || provider.type === "oidc") { @@ -66,13 +65,13 @@ export async function callback( // 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) { + if (config.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 parsedState = await state.decode(params.state, config) const shouldRedirect = parsedState?.origin && - new URL(parsedState.origin).origin !== options.url.origin + new URL(parsedState.origin).origin !== config.url.origin if (shouldRedirect) { const proxyRedirect = `${parsedState.origin}?${new URLSearchParams(params)}` logger.debug("Proxy redirecting to", proxyRedirect) @@ -83,7 +82,7 @@ export async function callback( const authorizationResult = await handleOAuth( params, request.cookies, - options + config ) if (authorizationResult.cookies.length) { @@ -126,7 +125,7 @@ export async function callback( account, profile: OAuthProfile, }, - options + config ) if (redirect) return { redirect, cookies } @@ -134,7 +133,7 @@ export async function callback( sessionStore.value, userFromProvider, account, - options + config ) if (useJwtSession) { @@ -157,9 +156,7 @@ export async function callback( if (token === null) { cookies.push(...sessionStore.clean()) } else { - const salt = options.cookies.sessionToken.name - // Encode token - const newToken = await jwt.encode({ ...jwt, token, salt }) + const newToken = await sessionConfig.seal(token) // Set cookie expiry date const cookieExpires = new Date() @@ -173,10 +170,10 @@ export async function callback( } else { // Save Session Token in cookie cookies.push({ - name: options.cookies.sessionToken.name, + name: config.cookies.sessionToken.name, value: (session as AdapterSession).sessionToken, options: { - ...options.cookies.sessionToken.options, + ...config.cookies.sessionToken.options, expires: (session as AdapterSession).expires, }, }) @@ -215,7 +212,7 @@ export async function callback( throw e } - const secret = provider.secret ?? options.secret + const secret = provider.secret ?? config.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 @@ -247,7 +244,7 @@ export async function callback( provider: provider.id, } - const redirect = await handleAuthorized({ user, account }, options) + const redirect = await handleAuthorized({ user, account }, config) if (redirect) return { redirect, cookies } // Sign user in @@ -255,12 +252,7 @@ export async function callback( user: loggedInUser, session, isNewUser, - } = await handleLoginOrRegister( - sessionStore.value, - user, - account, - options - ) + } = await handleLoginOrRegister(sessionStore.value, user, account, config) if (useJwtSession) { const defaultToken = { @@ -269,7 +261,7 @@ export async function callback( picture: loggedInUser.image, sub: loggedInUser.id?.toString(), } - const token = await callbacks.jwt({ + const payload = await callbacks.jwt({ token: defaultToken, user: loggedInUser, account, @@ -277,13 +269,11 @@ export async function callback( trigger: isNewUser ? "signUp" : "signIn", }) - // Clear cookies if token is null - if (token === null) { + // Clear cookies if payload is null + if (payload === null) { cookies.push(...sessionStore.clean()) } else { - const salt = options.cookies.sessionToken.name - // Encode token - const newToken = await jwt.encode({ ...jwt, token, salt }) + const newToken = await sessionConfig.seal(payload) // Set cookie expiry date const cookieExpires = new Date() @@ -297,10 +287,10 @@ export async function callback( } else { // Save Session Token in cookie cookies.push({ - name: options.cookies.sessionToken.name, + name: config.cookies.sessionToken.name, value: (session as AdapterSession).sessionToken, options: { - ...options.cookies.sessionToken.options, + ...config.cookies.sessionToken.options, expires: (session as AdapterSession).expires, }, }) @@ -347,7 +337,7 @@ export async function callback( const redirect = await handleAuthorized( { user, account, credentials }, - options + config ) if (redirect) return { redirect, cookies } @@ -358,7 +348,7 @@ export async function callback( sub: user.id, } - const token = await callbacks.jwt({ + const payload = await callbacks.jwt({ token: defaultToken, user, account, @@ -366,13 +356,12 @@ export async function callback( trigger: "signIn", }) - // Clear cookies if token is null - if (token === null) { + // Clear cookies if payload is null + if (payload === null) { cookies.push(...sessionStore.clean()) } else { - const salt = options.cookies.sessionToken.name // Encode token - const newToken = await jwt.encode({ ...jwt, token, salt }) + const newToken = await sessionConfig.seal(payload) // Set cookie expiry date const cookieExpires = new Date() @@ -399,7 +388,7 @@ export async function callback( } // Return an error if the adapter is missing or if the provider // is not a webauthn provider. - const localOptions = assertInternalOptionsWebAuthn(options) + const localOptions = assertInternalOptionsWebAuthn(config) // Verify request to get user, account and authenticator let user: User @@ -419,7 +408,7 @@ export async function callback( break } case "register": { - const verified = await verifyRegister(options, request, cookies) + const verified = await verifyRegister(request, config) user = verified.user account = verified.account @@ -430,7 +419,7 @@ export async function callback( } // Check if user is allowed to sign in - await handleAuthorized({ user, account }, options) + await handleAuthorized({ user, account }, config) // Sign user in, creating them and their account if needed const { @@ -438,12 +427,7 @@ export async function callback( isNewUser, session, account: currentAccount, - } = await handleLoginOrRegister( - sessionStore.value, - user, - account, - options - ) + } = await handleLoginOrRegister(sessionStore.value, user, account, config) if (!currentAccount) { // This is mostly for type checking. It should never actually happen. @@ -466,7 +450,7 @@ export async function callback( picture: loggedInUser.image, sub: loggedInUser.id?.toString(), } - const token = await callbacks.jwt({ + const payload = await callbacks.jwt({ token: defaultToken, user: loggedInUser, account: currentAccount, @@ -474,13 +458,11 @@ export async function callback( trigger: isNewUser ? "signUp" : "signIn", }) - // Clear cookies if token is null - if (token === null) { + // Clear cookies if payload is null + if (payload === null) { cookies.push(...sessionStore.clean()) } else { - const salt = options.cookies.sessionToken.name - // Encode token - const newToken = await jwt.encode({ ...jwt, token, salt }) + const newToken = await sessionConfig.seal(payload) // Set cookie expiry date const cookieExpires = new Date() @@ -494,10 +476,10 @@ export async function callback( } else { // Save Session Token in cookie cookies.push({ - name: options.cookies.sessionToken.name, + name: config.cookies.sessionToken.name, value: (session as AdapterSession).sessionToken, options: { - ...options.cookies.sessionToken.options, + ...config.cookies.sessionToken.options, expires: (session as AdapterSession).expires, }, }) @@ -537,8 +519,8 @@ export async function callback( } async function handleAuthorized( - params: Parameters[0], - config: InternalOptions + params: Parameters[0], + config: InternalConfig ): Promise { let authorized const { signIn, redirect } = config.callbacks diff --git a/packages/core/src/lib/actions/callback/oauth/callback.ts b/packages/core/src/lib/actions/callback/oauth/callback.ts index 988ead5b10..be56a5aade 100644 --- a/packages/core/src/lib/actions/callback/oauth/callback.ts +++ b/packages/core/src/lib/actions/callback/oauth/callback.ts @@ -7,7 +7,7 @@ import { import type { Account, - InternalOptions, + InternalConfig, LoggerInstance, Profile, RequestInternal, @@ -47,9 +47,9 @@ function clientSecretBasic(clientId: string, clientSecret: string) { export async function handleOAuth( params: RequestInternal["query"], cookies: RequestInternal["cookies"], - options: InternalOptions<"oauth" | "oidc"> + config: InternalConfig<"oauth" | "oidc"> ) { - const { logger, provider } = options + const { logger, provider } = config let as: o.AuthorizationServer @@ -127,7 +127,7 @@ export async function handleOAuth( const resCookies: Cookie[] = [] - const state = await checks.state.use(cookies, resCookies, options) + const state = await checks.state.use(cookies, resCookies, config) let codeGrantParams: URLSearchParams try { @@ -149,10 +149,10 @@ export async function handleOAuth( throw err } - const codeVerifier = await checks.pkce.use(cookies, resCookies, options) + const codeVerifier = await checks.pkce.use(cookies, resCookies, config) let redirect_uri = provider.callbackUrl - if (!options.isOnRedirectProxy && provider.redirectProxyUrl) { + if (!config.isOnRedirectProxy && provider.redirectProxyUrl) { redirect_uri = provider.redirectProxyUrl } @@ -217,7 +217,7 @@ export async function handleOAuth( client, codeGrantResponse, { - expectedNonce: await checks.nonce.use(cookies, resCookies, options), + expectedNonce: await checks.nonce.use(cookies, resCookies, config), requireIdToken, } ) diff --git a/packages/core/src/lib/actions/callback/oauth/checks.ts b/packages/core/src/lib/actions/callback/oauth/checks.ts index 8d8fac115b..83aa93c56f 100644 --- a/packages/core/src/lib/actions/callback/oauth/checks.ts +++ b/packages/core/src/lib/actions/callback/oauth/checks.ts @@ -2,11 +2,11 @@ import * as o from "oauth4webapi" import { InvalidCheck } from "../../../../errors.js" // NOTE: We use the default JWT methods here because they encrypt/decrypt the payload, not just sign it. -import { decode, encode } from "../../../../jwt.js" +import { decode as unseal, encode as seal } from "../../../../jwt.js" import type { CookiesOptions, - InternalOptions, + InternalConfig, RequestInternal, User, } from "../../../../types.js" @@ -23,9 +23,9 @@ const COOKIE_TTL = 60 * 15 // 15 minutes async function sealCookie( name: keyof CookiesOptions, payload: string, - options: InternalOptions<"oauth" | "oidc" | WebAuthnProviderType> + config: InternalConfig<"oauth" | "oidc" | WebAuthnProviderType> ): Promise { - const { cookies, logger } = options + const { cookies, logger } = config const cookie = cookies[name] const expires = new Date() expires.setTime(expires.getTime() + COOKIE_TTL * 1000) @@ -37,32 +37,33 @@ async function sealCookie( expires, }) - const encoded = await encode({ - ...options.jwt, + const sealed = await seal({ + secret: config.secret, maxAge: COOKIE_TTL, token: { value: payload } satisfies CookiePayload, salt: cookie.name, }) + const cookieOptions = { ...cookie.options, expires } - return { name: cookie.name, value: encoded, options: cookieOptions } + return { name: cookie.name, value: sealed, options: cookieOptions } } async function parseCookie( name: keyof CookiesOptions, value: string | undefined, - options: InternalOptions + config: InternalConfig ): Promise { try { - const { logger, cookies, jwt } = options + const { logger, cookies } = config logger.debug(`PARSE_${name.toUpperCase()}`, { cookie: value }) if (!value) throw new InvalidCheck(`${name} cookie was missing`) - const parsed = await decode({ - ...jwt, + const unsealed = await unseal({ + secret: config.secret, token: value, salt: cookies[name].name, }) - if (parsed?.value) return parsed.value + if (unsealed?.value) return unsealed.value throw new Error("Invalid cookie") } catch (error) { throw new InvalidCheck(`${name} value could not be parsed`, { @@ -73,10 +74,10 @@ async function parseCookie( function clearCookie( name: keyof CookiesOptions, - options: InternalOptions, + config: InternalConfig, resCookies: Cookie[] ) { - const { logger, cookies } = options + const { logger, cookies } = config const cookie = cookies[name] logger.debug(`CLEAR_${name.toUpperCase()}`, { cookie }) resCookies.push({ @@ -93,14 +94,14 @@ function useCookie( return async function ( cookies: RequestInternal["cookies"], resCookies: Cookie[], - options: InternalOptions<"oidc"> + config: InternalConfig<"oidc"> ) { - const { provider, logger } = options + const { provider, logger } = config if (!provider?.checks?.includes(check)) return - const cookieValue = cookies?.[options.cookies[name].name] + const cookieValue = cookies?.[config.cookies[name].name] logger.debug(`USE_${name.toUpperCase()}`, { value: cookieValue }) - const parsed = await parseCookie(name, cookieValue, options) - clearCookie(name, options, resCookies) + const parsed = await parseCookie(name, cookieValue, config) + clearCookie(name, config, resCookies) return parsed } } @@ -111,10 +112,10 @@ function useCookie( */ export const pkce = { /** Creates a PKCE code challenge and verifier pair. The verifier in stored in the cookie. */ - async create(options: InternalOptions<"oauth">) { + async create(config: InternalConfig<"oauth">) { const code_verifier = o.generateRandomCodeVerifier() const value = await o.calculatePKCECodeChallenge(code_verifier) - const cookie = await sealCookie("pkceCodeVerifier", code_verifier, options) + const cookie = await sealCookie("pkceCodeVerifier", code_verifier, config) return { cookie, value } }, /** @@ -139,8 +140,8 @@ const encodedStateSalt = "encodedState" */ export const state = { /** Creates a state cookie with an optionally encoded body. */ - async create(options: InternalOptions<"oauth">, origin?: string) { - const { provider } = options + async create(config: InternalConfig<"oauth">, origin?: string) { + const { provider } = config if (!provider.checks.includes("state")) { if (origin) { throw new InvalidCheck( @@ -155,13 +156,13 @@ export const state = { origin, random: o.generateRandomState(), } satisfies EncodedState - const value = await encode({ - secret: options.jwt.secret, + const value = await seal({ + secret: config.secret, token: payload, salt: encodedStateSalt, maxAge: STATE_MAX_AGE, }) - const cookie = await sealCookie("state", value, options) + const cookie = await sealCookie("state", value, config) return { cookie, value } }, @@ -172,11 +173,11 @@ export const state = { */ use: useCookie("state", "state"), /** Decodes the state. If it could not be decoded, it throws an error. */ - async decode(state: string, options: InternalOptions) { + async decode(state: string, config: InternalConfig) { try { - options.logger.debug("DECODE_STATE", { state }) - const payload = await decode({ - secret: options.jwt.secret, + config.logger.debug("DECODE_STATE", { state }) + const payload = await unseal({ + secret: config.secret, token: state, salt: encodedStateSalt, }) @@ -189,10 +190,10 @@ export const state = { } export const nonce = { - async create(options: InternalOptions<"oidc">) { - if (!options.provider.checks.includes("nonce")) return + async create(config: InternalConfig<"oidc">) { + if (!config.provider.checks.includes("nonce")) return const value = o.generateRandomNonce() - const cookie = await sealCookie("nonce", value, options) + const cookie = await sealCookie("nonce", value, config) return { cookie, value } }, /** @@ -215,41 +216,41 @@ interface WebAuthnChallengePayload { const webauthnChallengeSalt = "encodedWebauthnChallenge" export const webauthnChallenge = { async create( - options: InternalOptions, + config: InternalConfig, challenge: string, registerData?: User ) { return { cookie: await sealCookie( "webauthnChallenge", - await encode({ - secret: options.jwt.secret, + await seal({ + secret: config.secret, token: { challenge, registerData } satisfies WebAuthnChallengePayload, salt: webauthnChallengeSalt, maxAge: WEBAUTHN_CHALLENGE_MAX_AGE, }), - options + config ), } }, /** Returns WebAuthn challenge if present. */ async use( - options: InternalOptions, + config: InternalConfig, cookies: RequestInternal["cookies"], resCookies: Cookie[] ): Promise { - const cookieValue = cookies?.[options.cookies.webauthnChallenge.name] + const cookieValue = cookies?.[config.cookies.webauthnChallenge.name] - const parsed = await parseCookie("webauthnChallenge", cookieValue, options) + const parsed = await parseCookie("webauthnChallenge", cookieValue, config) - const payload = await decode({ - secret: options.jwt.secret, + const payload = await unseal({ + secret: config.secret, token: parsed, salt: webauthnChallengeSalt, }) // Clear the WebAuthn challenge cookie after use - clearCookie("webauthnChallenge", options, resCookies) + clearCookie("webauthnChallenge", config, resCookies) if (!payload) throw new InvalidCheck("WebAuthn challenge was missing") diff --git a/packages/core/src/lib/actions/callback/oauth/csrf-token.ts b/packages/core/src/lib/actions/callback/oauth/csrf-token.ts index 337c1d62dd..84444fd91c 100644 --- a/packages/core/src/lib/actions/callback/oauth/csrf-token.ts +++ b/packages/core/src/lib/actions/callback/oauth/csrf-token.ts @@ -1,9 +1,9 @@ import { createHash, randomString } from "../../../utils/web.js" -import type { AuthAction, InternalOptions } from "../../../../types.js" +import type { AuthAction, InternalConfig } from "../../../../types.js" import { MissingCSRF } from "../../../../errors.js" interface CreateCSRFTokenParams { - options: InternalOptions + config: InternalConfig cookieValue?: string isPost: boolean bodyValue?: string @@ -24,7 +24,7 @@ interface CreateCSRFTokenParams { * https://owasp.org/www-chapter-london/assets/slides/David_Johansson-Double_Defeat_of_Double-Submit_Cookie.pdf */ export async function createCSRFToken({ - options, + config, cookieValue, isPost, bodyValue, @@ -33,7 +33,7 @@ export async function createCSRFToken({ const [csrfToken, csrfTokenHash] = cookieValue.split("|") const expectedCsrfTokenHash = await createHash( - `${csrfToken}${options.secret}` + `${csrfToken}${config.secret}` ) if (csrfTokenHash === expectedCsrfTokenHash) { @@ -48,7 +48,7 @@ export async function createCSRFToken({ // New CSRF token const csrfToken = randomString(32) - const csrfTokenHash = await createHash(`${csrfToken}${options.secret}`) + const csrfTokenHash = await createHash(`${csrfToken}${config.secret}`) const cookie = `${csrfToken}|${csrfTokenHash}` return { cookie, csrfToken } diff --git a/packages/core/src/lib/actions/session.ts b/packages/core/src/lib/actions/session.ts index 7ff6f7f357..93df95da1c 100644 --- a/packages/core/src/lib/actions/session.ts +++ b/packages/core/src/lib/actions/session.ts @@ -2,26 +2,25 @@ import { JWTSessionError, SessionTokenError } from "../../errors.js" import { fromDate } from "../utils/date.js" import type { Adapter } from "../../adapters.js" -import type { InternalOptions, ResponseInternal, Session } from "../../types.js" -import type { Cookie, SessionStore } from "../utils/cookie.js" +import type { InternalConfig, ResponseInternal, Session } from "../../types.js" /** Return a session object filtered via `callbacks.session` */ export async function session( - options: InternalOptions, - sessionStore: SessionStore, - cookies: Cookie[], + config: InternalConfig, isUpdate?: boolean, newSession?: any ): Promise> { const { adapter, - jwt, events, callbacks, logger, - session: { strategy: sessionStrategy, maxAge: sessionMaxAge }, - } = options + session: sessionConfig, + resCookies: cookies, + sessionStore, + } = config + const sessionMaxAge = sessionConfig.maxAge const response: ResponseInternal = { body: null, headers: { "Content-Type": "application/json" }, @@ -32,10 +31,9 @@ export async function session( if (!sessionToken) return response - if (sessionStrategy === "jwt") { + if (!sessionConfig.isDatabase) { try { - const salt = options.cookies.sessionToken.name - const payload = await jwt.decode({ ...jwt, token: sessionToken, salt }) + const payload = await sessionConfig.unseal(sessionToken) if (!payload) throw new Error("Invalid JWT") @@ -62,7 +60,7 @@ export async function session( response.body = newSession // Refresh JWT expiry by re-signing it, with an updated expiry date - const newToken = await jwt.encode({ ...jwt, token, salt }) + const newToken = await sessionConfig.seal(token) // Set cookie, to also update expiry date on cookie const sessionCookies = sessionStore.chunk(newToken, { @@ -102,7 +100,7 @@ export async function session( if (userAndSession) { const { user, session } = userAndSession - const sessionUpdateAge = options.session.updateAge + const sessionUpdateAge = config.session.updateAge // Calculate last updated date to throttle write updates to database // Formula: ({expiry date} - sessionMaxAge) + sessionUpdateAge // e.g. ({expiry date} - 30 days) + 1 hour @@ -137,10 +135,10 @@ export async function session( // Set cookie again to update expiry response.cookies?.push({ - name: options.cookies.sessionToken.name, + name: config.cookies.sessionToken.name, value: sessionToken, options: { - ...options.cookies.sessionToken.options, + ...config.cookies.sessionToken.options, expires: newExpires, }, }) diff --git a/packages/core/src/lib/actions/signin/authorization-url.ts b/packages/core/src/lib/actions/signin/authorization-url.ts index ac7d96aeee..a254097995 100644 --- a/packages/core/src/lib/actions/signin/authorization-url.ts +++ b/packages/core/src/lib/actions/signin/authorization-url.ts @@ -1,7 +1,7 @@ import * as checks from "../callback/oauth/checks.js" import * as o from "oauth4webapi" -import type { InternalOptions, RequestInternal } from "../../../types.js" +import type { InternalConfig, RequestInternal } from "../../../types.js" import type { Cookie } from "../../utils/cookie.js" import { customFetch } from "../../symbols.js" @@ -12,9 +12,9 @@ import { customFetch } from "../../symbols.js" */ export async function getAuthorizationUrl( query: RequestInternal["query"], - options: InternalOptions<"oauth" | "oidc"> + config: InternalConfig<"oauth" | "oidc"> ) { - const { logger, provider } = options + const { logger, provider } = config let url = provider.authorization?.url let as: o.AuthorizationServer | undefined @@ -45,7 +45,7 @@ export async function getAuthorizationUrl( let redirect_uri: string = provider.callbackUrl let data: string | undefined - if (!options.isOnRedirectProxy && provider.redirectProxyUrl) { + if (!config.isOnRedirectProxy && provider.redirectProxyUrl) { redirect_uri = provider.redirectProxyUrl data = provider.callbackUrl logger.debug("using redirect proxy", { redirect_uri, data }) @@ -73,13 +73,13 @@ export async function getAuthorizationUrl( provider.authorization?.url.searchParams.get("response_mode") === "form_post" ) { - options.cookies.state.options.sameSite = "none" - options.cookies.state.options.secure = true - options.cookies.nonce.options.sameSite = "none" - options.cookies.nonce.options.secure = true + config.cookies.state.options.sameSite = "none" + config.cookies.state.options.secure = true + config.cookies.nonce.options.sameSite = "none" + config.cookies.nonce.options.secure = true } - const state = await checks.state.create(options, data) + const state = await checks.state.create(config, data) if (state) { authParams.set("state", state.value) cookies.push(state.cookie) @@ -91,14 +91,14 @@ export async function getAuthorizationUrl( // a random `nonce` must be used for CSRF protection. if (provider.type === "oidc") provider.checks = ["nonce"] } else { - const { value, cookie } = await checks.pkce.create(options) + const { value, cookie } = await checks.pkce.create(config) authParams.set("code_challenge", value) authParams.set("code_challenge_method", "S256") cookies.push(cookie) } } - const nonce = await checks.nonce.create(options) + const nonce = await checks.nonce.create(config) if (nonce) { authParams.set("nonce", nonce.value) cookies.push(nonce.cookie) diff --git a/packages/core/src/lib/actions/signin/index.ts b/packages/core/src/lib/actions/signin/index.ts index e47c934ea0..e03ce4de58 100644 --- a/packages/core/src/lib/actions/signin/index.ts +++ b/packages/core/src/lib/actions/signin/index.ts @@ -1,34 +1,33 @@ import { getAuthorizationUrl } from "./authorization-url.js" import { sendToken } from "./send-token.js" -import type { Cookie } from "../../utils/cookie.js" import type { - InternalOptions, + InternalConfig, RequestInternal, ResponseInternal, } from "../../../types.js" export async function signIn( request: RequestInternal, - cookies: Cookie[], - options: InternalOptions + config: InternalConfig ): Promise { - const signInUrl = `${options.url.origin}${options.basePath}/signin` + const { resCookies: cookies } = config + const signInUrl = `${config.url.origin}${config.basePath}/signin` - if (!options.provider) return { redirect: signInUrl, cookies } + if (!config.provider) return { redirect: signInUrl, cookies } - switch (options.provider.type) { + switch (config.provider.type) { case "oauth": case "oidc": { const { redirect, cookies: authCookies } = await getAuthorizationUrl( request.query, - options + config ) if (authCookies) cookies.push(...authCookies) return { redirect, cookies } } case "email": { - const response = await sendToken(request, options) + const response = await sendToken(request, config) return { ...response, cookies } } default: diff --git a/packages/core/src/lib/actions/signin/send-token.ts b/packages/core/src/lib/actions/signin/send-token.ts index 27024affaf..4055996f02 100644 --- a/packages/core/src/lib/actions/signin/send-token.ts +++ b/packages/core/src/lib/actions/signin/send-token.ts @@ -1,7 +1,7 @@ import { createHash, randomString, toRequest } from "../../utils/web.js" import { AccessDenied } from "../../../errors.js" -import type { InternalOptions, RequestInternal } from "../../../types.js" +import type { InternalConfig, RequestInternal } from "../../../types.js" import type { Account } from "../../../types.js" /** @@ -11,10 +11,10 @@ import type { Account } from "../../../types.js" */ export async function sendToken( request: RequestInternal, - options: InternalOptions<"email"> + config: InternalConfig<"email"> ) { const { body } = request - const { provider, callbacks, adapter } = options + const { provider, callbacks, adapter } = config const normalizer = provider.normalizeIdentifier ?? defaultNormalizer const email = normalizer(body?.email) @@ -43,12 +43,12 @@ export async function sendToken( return { redirect: await callbacks.redirect({ url: authorized, - baseUrl: options.url.origin, + baseUrl: config.url.origin, }), } } - const { callbackUrl, theme } = options + const { callbackUrl, theme } = config const token = (await provider.generateVerificationToken?.()) ?? randomString(32) @@ -57,9 +57,9 @@ export async function sendToken( Date.now() + (provider.maxAge ?? ONE_DAY_IN_SECONDS) * 1000 ) - const secret = provider.secret ?? options.secret + const secret = provider.secret ?? config.secret - const baseUrl = new URL(options.basePath, options.url.origin) + const baseUrl = new URL(config.basePath, config.url.origin) const sendRequest = provider.sendVerificationRequest({ identifier: email, diff --git a/packages/core/src/lib/actions/signout.ts b/packages/core/src/lib/actions/signout.ts index b09c90933b..c787a0ac98 100644 --- a/packages/core/src/lib/actions/signout.ts +++ b/packages/core/src/lib/actions/signout.ts @@ -1,7 +1,6 @@ import { SignOutError } from "../../errors.js" -import type { InternalOptions, ResponseInternal } from "../../types.js" -import type { Cookie, SessionStore } from "../utils/cookie.js" +import type { InternalConfig, ResponseInternal } from "../../types.js" /** * Destroys the session. @@ -11,22 +10,26 @@ import type { Cookie, SessionStore } from "../utils/cookie.js" * {@link AuthConfig["events"].signOut} is emitted. */ export async function signOut( - cookies: Cookie[], - sessionStore: SessionStore, - options: InternalOptions + config: InternalConfig ): Promise { - const { jwt, events, callbackUrl: redirect, logger, session } = options + const { + events, + callbackUrl: redirect, + logger, + session, + resCookies: cookies, + sessionStore, + } = config const sessionToken = sessionStore.value if (!sessionToken) return { redirect, cookies } try { - if (session.strategy === "jwt") { - const salt = options.cookies.sessionToken.name - const token = await jwt.decode({ ...jwt, token: sessionToken, salt }) - await events.signOut?.({ token }) - } else { - const session = await options.adapter?.deleteSession(sessionToken) + if (session.isDatabase) { + const session = await config.adapter?.deleteSession(sessionToken) await events.signOut?.({ session }) + } else { + const token = await session.unseal(sessionToken) + await events.signOut?.({ token }) } } catch (e) { logger.error(new SignOutError(e as Error)) diff --git a/packages/core/src/lib/actions/webauthn-options.ts b/packages/core/src/lib/actions/webauthn-options.ts index f34e4cc3e4..07695cacf2 100644 --- a/packages/core/src/lib/actions/webauthn-options.ts +++ b/packages/core/src/lib/actions/webauthn-options.ts @@ -1,10 +1,9 @@ import type { - InternalOptions, + InternalConfig, RequestInternal, ResponseInternal, User, } from "../../types.js" -import type { Cookie, SessionStore } from "../utils/cookie.js" import { getLoggedInUser } from "../utils/session.js" import { assertInternalOptionsWebAuthn, @@ -19,18 +18,17 @@ import { */ export async function webAuthnOptions( request: RequestInternal, - options: InternalOptions, - sessionStore: SessionStore, - cookies: Cookie[] + config: InternalConfig // @ts-expect-error issue with returning from a switch case ): Promise { // Return an error if the adapter is missing or if the provider // is not a webauthn provider. - const narrowOptions = assertInternalOptionsWebAuthn(options) + const narrowOptions = assertInternalOptionsWebAuthn(config) const { provider } = narrowOptions // Extract the action from the query parameters const { action } = (request.query ?? {}) as Record + const { resCookies: cookies } = config // Action must be either "register", "authenticate", or undefined if ( @@ -49,7 +47,7 @@ export async function webAuthnOptions( } // Get the user info from the session - const sessionUser = await getLoggedInUser(options, sessionStore) + const sessionUser = await getLoggedInUser(config) // Extract user info from request // If session user exists, we don't need to call getUserInfo @@ -58,7 +56,7 @@ export async function webAuthnOptions( user: sessionUser, exists: true, } - : await provider.getUserInfo(options, request) + : await provider.getUserInfo(config, request) const userInfo = getUserInfoResponse?.user diff --git a/packages/core/src/lib/index.ts b/packages/core/src/lib/index.ts index 00ee0027c1..a589259578 100644 --- a/packages/core/src/lib/index.ts +++ b/packages/core/src/lib/index.ts @@ -1,5 +1,4 @@ import { UnknownAction } from "../errors.js" -import { SessionStore } from "./utils/cookie.js" import { init } from "./init.js" import renderPage from "./pages/index.js" import * as actions from "./actions/index.js" @@ -9,87 +8,57 @@ import type { RequestInternal, ResponseInternal } from "../types.js" import type { AuthConfig } from "../index.js" import { skipCSRFCheck } from "./symbols.js" -export { customFetch, raw, skipCSRFCheck } from "./symbols.js" - /** @internal */ export async function AuthInternal( request: RequestInternal, - authOptions: AuthConfig + userConfig: AuthConfig ): Promise { - const { action, providerId, error, method } = request - - const csrfDisabled = authOptions.skipCSRFCheck === skipCSRFCheck + const { action, error, method } = request - const { options, cookies } = await init({ - authOptions, - action, - providerId, - url: request.url, - callbackUrl: request.body?.callbackUrl ?? request.query?.callbackUrl, - csrfToken: request.body?.csrfToken, - cookies: request.cookies, - isPost: method === "POST", - csrfDisabled, - }) + const internalConfig = await init(request, userConfig) - const sessionStore = new SessionStore( - options.cookies.sessionToken, - request.cookies, - options.logger - ) + const csrfDisabled = userConfig.skipCSRFCheck === skipCSRFCheck if (method === "GET") { - const render = renderPage({ ...options, query: request.query, cookies }) + const render = renderPage(internalConfig) switch (action) { case "callback": - return await actions.callback(request, options, sessionStore, cookies) + return await actions.callback(request, internalConfig) case "csrf": - return render.csrf(csrfDisabled, options, cookies) + return render.csrf(csrfDisabled) case "error": return render.error(error) case "providers": - return render.providers(options.providers) + return render.providers() case "session": - return await actions.session(options, sessionStore, cookies) + return await actions.session(internalConfig) case "signin": - return render.signin(providerId, error) + return render.signin(request) case "signout": return render.signout() case "verify-request": return render.verifyRequest() case "webauthn-options": - return await actions.webAuthnOptions( - request, - options, - sessionStore, - cookies - ) + return await actions.webAuthnOptions(request, internalConfig) default: } } else { - const { csrfTokenVerified } = options + const { csrfTokenVerified } = internalConfig switch (action) { case "callback": - if (options.provider.type === "credentials") + if (internalConfig.provider.type === "credentials") // Verified CSRF Token required for credentials providers only validateCSRF(action, csrfTokenVerified) - return await actions.callback(request, options, sessionStore, cookies) + return await actions.callback(request, internalConfig) case "session": validateCSRF(action, csrfTokenVerified) - return await actions.session( - options, - sessionStore, - cookies, - true, - request.body?.data - ) + return await actions.session(internalConfig, true, request.body?.data) case "signin": validateCSRF(action, csrfTokenVerified) - return await actions.signIn(request, cookies, options) - + return await actions.signIn(request, internalConfig) case "signout": validateCSRF(action, csrfTokenVerified) - return await actions.signOut(cookies, sessionStore, options) + return await actions.signOut(internalConfig) default: } } diff --git a/packages/core/src/lib/init.ts b/packages/core/src/lib/init.ts index 9e8ca122f6..fcf91542bb 100644 --- a/packages/core/src/lib/init.ts +++ b/packages/core/src/lib/init.ts @@ -4,29 +4,15 @@ import * as cookie from "./utils/cookie.js" import { createCSRFToken } from "./actions/callback/oauth/csrf-token.js" import { AdapterError, EventError } from "../errors.js" -import parseProviders from "./utils/providers.js" +import parseProviders, { isOAuthProvider } from "./utils/providers.js" import { setLogger, type LoggerInstance } from "./utils/logger.js" import { merge } from "./utils/merge.js" +import { skipCSRFCheck } from "./symbols.js" -import type { InternalOptions, RequestInternal } from "../types.js" +import type { InternalConfig, RequestInternal } from "../types.js" import type { AuthConfig } from "../index.js" -interface InitParams { - url: URL - authOptions: AuthConfig - providerId?: string - action: InternalOptions["action"] - /** Callback URL value extracted from the incoming request. */ - callbackUrl?: string - /** CSRF token value extracted from the incoming request. From body if POST, from query if GET */ - csrfToken?: string - /** Is the incoming request a POST request? */ - csrfDisabled: boolean - isPost: boolean - cookies: RequestInternal["cookies"] -} - -export const defaultCallbacks: InternalOptions["callbacks"] = { +export const defaultCallbacks: InternalConfig["callbacks"] = { signIn() { return true }, @@ -50,31 +36,28 @@ export const defaultCallbacks: InternalOptions["callbacks"] = { }, } -/** Initialize all internal options and cookies. */ -export async function init({ - authOptions: config, - providerId, - action, - url, - cookies: reqCookies, - callbackUrl: reqCallbackUrl, - csrfToken: reqCsrfToken, - csrfDisabled, - isPost, -}: InitParams): Promise<{ - options: InternalOptions - cookies: cookie.Cookie[] -}> { - const logger = setLogger(config) - const { providers, provider } = parseProviders({ url, providerId, config }) - - const maxAge = 30 * 24 * 60 * 60 // Sessions expire after 30 days of being idle by default +/** Initialize all internal options. */ +export async function init( + request: RequestInternal, + userConfig: AuthConfig +): Promise { + const logger = setLogger(userConfig) + + const { providerId, action, url, cookies: reqCookies } = request + const isPost = request.method === "POST" + const csrfDisabled = userConfig.skipCSRFCheck === skipCSRFCheck + + const reqCallbackUrl = request.body?.callbackUrl ?? request.query?.callbackUrl + const reqCsrfToken = request.body?.csrfToken + + const { providers, provider } = parseProviders({ + url, + providerId, + config: userConfig, + }) let isOnRedirectProxy = false - if ( - (provider?.type === "oauth" || provider?.type === "oidc") && - provider.redirectProxyUrl - ) { + if (isOAuthProvider(provider) && provider.redirectProxyUrl) { try { isOnRedirectProxy = new URL(provider.redirectProxyUrl).origin === url.origin @@ -85,10 +68,21 @@ export async function init({ } } + const cookies = merge( + cookie.defaultCookies( + userConfig.useSecureCookies ?? url.protocol === "https:" + ), + userConfig.cookies + ) + + const sessionSecret = userConfig.jwt?.secret ?? userConfig.secret! // Asserted in assert.ts + const sessionSalt = cookies.sessionToken.name + const unseal = userConfig.jwt?.decode ?? jwt.decode + const seal = userConfig.jwt?.encode ?? jwt.encode + // User provided options are overridden by other options, // except for the options with special handling above - const options: InternalOptions = { - debug: false, + const config: InternalConfig = { pages: {}, theme: { colorScheme: "auto", @@ -97,104 +91,107 @@ export async function init({ buttonText: "", }, // Custom options override defaults - ...config, + ...userConfig, // These computed settings can have values in userOptions but we override them // and are request-specific. url, action, // @ts-expect-errors provider, - cookies: merge( - cookie.defaultCookies( - config.useSecureCookies ?? url.protocol === "https:" - ), - config.cookies - ), + cookies, providers, // Session options session: { - // If no adapter specified, force use of JSON Web Tokens (stateless) - strategy: config.adapter ? "database" : "jwt", - maxAge, - updateAge: 24 * 60 * 60, - generateSessionToken: () => crypto.randomUUID(), - ...config.session, - }, - // JWT options - jwt: { - secret: config.secret!, // Asserted in assert.ts - maxAge: config.session?.maxAge ?? maxAge, // default to same as `session.maxAge` - encode: jwt.encode, - decode: jwt.decode, - ...config.jwt, + isDatabase: !userConfig.session?.strategy + ? !!userConfig.adapter + : userConfig.session.strategy === "database", + generateSessionToken() { + return crypto.randomUUID() + }, + updateAge: 24 * 60 * 60, // Sessions are updated if they are within 24 hours of expiry by default + maxAge: userConfig.jwt?.maxAge ?? 30 * 24 * 60 * 60, // Sessions expire after 30 days of being idle by default + unseal(value) { + return unseal({ + secret: sessionSecret, + token: value, + salt: sessionSalt, + }) + }, + seal(payload) { + return seal({ + secret: sessionSecret, + token: payload, + salt: sessionSalt, + }) + }, }, // Event messages - events: eventsErrorHandler(config.events ?? {}, logger), - adapter: adapterErrorHandler(config.adapter, logger), + events: eventsErrorHandler(userConfig.events ?? {}, logger), + adapter: adapterErrorHandler(userConfig.adapter, logger), // Callback functions - callbacks: { ...defaultCallbacks, ...config.callbacks }, + callbacks: { ...defaultCallbacks, ...userConfig.callbacks }, logger, callbackUrl: url.origin, isOnRedirectProxy, - experimental: { - ...config.experimental, - }, + experimental: { ...userConfig.experimental }, + resCookies: [], + sessionStore: new cookie.SessionStore( + cookies.sessionToken, + reqCookies, + logger + ), } - // Init cookies - - const cookies: cookie.Cookie[] = [] - if (csrfDisabled) { - options.csrfTokenVerified = true + config.csrfTokenVerified = true } else { const { csrfToken, cookie: csrfCookie, csrfTokenVerified, } = await createCSRFToken({ - options, - cookieValue: reqCookies?.[options.cookies.csrfToken.name], + config: config, + cookieValue: reqCookies?.[config.cookies.csrfToken.name], isPost, bodyValue: reqCsrfToken, }) - options.csrfToken = csrfToken - options.csrfTokenVerified = csrfTokenVerified + config.csrfToken = csrfToken + config.csrfTokenVerified = csrfTokenVerified if (csrfCookie) { - cookies.push({ - name: options.cookies.csrfToken.name, + config.resCookies.push({ + name: config.cookies.csrfToken.name, value: csrfCookie, - options: options.cookies.csrfToken.options, + options: config.cookies.csrfToken.options, }) } } const { callbackUrl, callbackUrlCookie } = await createCallbackUrl({ - options, - cookieValue: reqCookies?.[options.cookies.callbackUrl.name], + config: config, + cookieValue: reqCookies?.[config.cookies.callbackUrl.name], paramValue: reqCallbackUrl, }) - options.callbackUrl = callbackUrl + config.callbackUrl = callbackUrl if (callbackUrlCookie) { - cookies.push({ - name: options.cookies.callbackUrl.name, + config.resCookies.push({ + name: config.cookies.callbackUrl.name, value: callbackUrlCookie, - options: options.cookies.callbackUrl.options, + options: config.cookies.callbackUrl.options, }) } - return { options, cookies } + return config } type Method = (...args: any[]) => Promise /** Wraps an object of methods and adds error handling. */ function eventsErrorHandler( - methods: Partial, + methods: Partial, logger: LoggerInstance -): Partial { +): Partial { return Object.keys(methods).reduce((acc, name) => { acc[name] = async (...args: any[]) => { try { diff --git a/packages/core/src/lib/pages/index.ts b/packages/core/src/lib/pages/index.ts index 2c91513a3f..89370f5293 100644 --- a/packages/core/src/lib/pages/index.ts +++ b/packages/core/src/lib/pages/index.ts @@ -7,13 +7,12 @@ import VerifyRequestPage from "./verify-request.js" import { UnknownAction } from "../../errors.js" import type { - InternalOptions, + InternalConfig, RequestInternal, ResponseInternal, InternalProvider, PublicProvider, } from "../../types.js" -import type { Cookie } from "../utils/cookie.js" function send({ html, @@ -35,44 +34,34 @@ function send({ } } -type RenderPageParams = { - query?: RequestInternal["query"] - cookies?: Cookie[] -} & Partial< - Pick< - InternalOptions, - "url" | "callbackUrl" | "csrfToken" | "providers" | "theme" | "pages" - > -> - /** * Unless the user defines their [own pages](https://authjs.dev/reference/core#pages), * we render a set of default ones, using Preact SSR. */ -export default function renderPage(params: RenderPageParams) { - const { url, theme, query, cookies, pages, providers } = params +export default function renderPage(config: Partial) { + const { url, theme, resCookies: cookies, pages, providers } = config return { - csrf(skip: boolean, options: InternalOptions, cookies: Cookie[]) { + csrf(skip: boolean) { if (!skip) { return { headers: { "Content-Type": "application/json" }, - body: { csrfToken: options.csrfToken }, + body: { csrfToken: config.csrfToken }, cookies, } } - options.logger.warn("csrf-disabled") - cookies.push({ - name: options.cookies.csrfToken.name, + config.logger?.warn("csrf-disabled") + cookies?.push({ + name: config.cookies!.csrfToken.name, value: "", - options: { ...options.cookies.csrfToken.options, maxAge: 0 }, + options: { ...config.cookies?.csrfToken.options, maxAge: 0 }, }) return { status: 404, cookies } }, - providers(providers: InternalProvider[]) { + providers() { return { headers: { "Content-Type": "application/json" }, - body: providers.reduce>( + body: providers?.reduce>( (acc, { id, name, type, signinUrl, callbackUrl }) => { acc[id] = { id, name, type, signinUrl, callbackUrl } return acc @@ -81,12 +70,13 @@ export default function renderPage(params: RenderPageParams) { ), } }, - signin(providerId?: string, error?: any) { + signin(request: RequestInternal) { + const { error, query, providerId } = request if (providerId) throw new UnknownAction("Unsupported action") if (pages?.signIn) { let signinUrl = `${pages.signIn}${ pages.signIn.includes("?") ? "&" : "?" - }${new URLSearchParams({ callbackUrl: params.callbackUrl ?? "/" })}` + }${new URLSearchParams({ callbackUrl: config.callbackUrl ?? "/" })}` if (error) signinUrl = `${signinUrl}&${new URLSearchParams({ error })}` return { redirect: signinUrl, cookies } } @@ -111,9 +101,9 @@ export default function renderPage(params: RenderPageParams) { cookies, theme, html: SigninPage({ - csrfToken: params.csrfToken, + csrfToken: config.csrfToken, // We only want to render providers - providers: params.providers?.filter( + providers: config.providers?.filter( (provider) => // Always render oauth and email type providers ["email", "oauth", "oidc"].includes(provider.type) || @@ -124,9 +114,9 @@ export default function renderPage(params: RenderPageParams) { // Don't render other provider types false ), - callbackUrl: params.callbackUrl, - theme: params.theme, - error, + callbackUrl: config.callbackUrl, + theme: config.theme, + error: error as any, ...query, }), title: "Sign In", @@ -138,7 +128,7 @@ export default function renderPage(params: RenderPageParams) { return send({ cookies, theme, - html: SignoutPage({ csrfToken: params.csrfToken, url, theme }), + html: SignoutPage({ csrfToken: config.csrfToken, url, theme }), title: "Sign Out", }) }, diff --git a/packages/core/src/lib/utils/callback-url.ts b/packages/core/src/lib/utils/callback-url.ts index 081335228a..e9ab79b4db 100644 --- a/packages/core/src/lib/utils/callback-url.ts +++ b/packages/core/src/lib/utils/callback-url.ts @@ -1,7 +1,7 @@ -import type { InternalOptions } from "../../types.js" +import type { InternalConfig } from "../../types.js" interface CreateCallbackUrlParams { - options: InternalOptions + config: InternalConfig /** Try reading value from request body (POST) then from query param (GET) */ paramValue?: string cookieValue?: string @@ -12,11 +12,11 @@ interface CreateCallbackUrlParams { * and add it to `req.options.callbackUrl`. */ export async function createCallbackUrl({ - options, + config, paramValue, cookieValue, }: CreateCallbackUrlParams) { - const { url, callbacks } = options + const { url, callbacks } = config let callbackUrl = url.origin diff --git a/packages/core/src/lib/utils/logger.ts b/packages/core/src/lib/utils/logger.ts index 031f614964..f42b1158ce 100644 --- a/packages/core/src/lib/utils/logger.ts +++ b/packages/core/src/lib/utils/logger.ts @@ -60,22 +60,30 @@ const defaultLogger: LoggerInstance = { /** * Override the built-in logger with user's implementation. - * Any `undefined` level will use the default logger. + * Any `undefined` level will use the default. */ export function setLogger( - config: Pick + config: Pick ): LoggerInstance { - const newLogger: LoggerInstance = { - ...defaultLogger, - } + const newLogger: LoggerInstance = { error() {}, warn() {}, debug() {} } + + let { debug, logLevel = "silent", logger } = config + if (debug) logLevel = "verbose" + + const levels = ["error", "warn", "debug"] + const levelIndex = levels.indexOf(logLevel) - // Turn off debug logging if `debug` isn't set to `true` - if (!config.debug) newLogger.debug = () => {} + for (const level of levels) { + if (levels.indexOf(level) <= levelIndex) { + newLogger[level] = defaultLogger[level] + } + } - if (config.logger?.error) newLogger.error = config.logger.error - if (config.logger?.warn) newLogger.warn = config.logger.warn - if (config.logger?.debug) newLogger.debug = config.logger.debug + // User preference overrides default logger + if (logger?.error) newLogger.error = logger.error + if (logger?.warn) newLogger.warn = logger.warn + if (logger?.debug) newLogger.debug = logger.debug + logger ??= newLogger - config.logger ??= newLogger return newLogger } diff --git a/packages/core/src/lib/utils/providers.ts b/packages/core/src/lib/utils/providers.ts index 8e8a4e66d0..925a6b9adc 100644 --- a/packages/core/src/lib/utils/providers.ts +++ b/packages/core/src/lib/utils/providers.ts @@ -192,7 +192,7 @@ export function isOAuth2Provider( /** Either OAuth 2 or OIDC */ export function isOAuthProvider( - provider: InternalProvider + provider?: InternalProvider ): provider is InternalProvider<"oauth" | "oidc"> { - return provider.type === "oauth" || provider.type === "oidc" + return provider?.type === "oauth" || provider?.type === "oidc" } diff --git a/packages/core/src/lib/utils/session.ts b/packages/core/src/lib/utils/session.ts index 11dc371933..3aa3a19913 100644 --- a/packages/core/src/lib/utils/session.ts +++ b/packages/core/src/lib/utils/session.ts @@ -1,41 +1,27 @@ -import type { InternalOptions, User } from "../../types.js" -import type { SessionStore } from "./cookie.js" +import type { InternalConfig, User } from "../../types.js" -/** - * Returns the currently logged in user, if any. - */ +/** Returns the currently logged in user, if any. */ export async function getLoggedInUser( - options: InternalOptions, - sessionStore: SessionStore + config: InternalConfig ): Promise { - const { - adapter, - jwt, - session: { strategy: sessionStrategy }, - } = options + const { adapter, session, sessionStore } = config - const sessionToken = sessionStore.value - if (!sessionToken) return null + const sessionCookie = sessionStore.value + if (!sessionCookie) return null - // Try to decode JWT - if (sessionStrategy === "jwt") { - const salt = options.cookies.sessionToken.name - const payload = await jwt.decode({ ...jwt, token: sessionToken, salt }) - - if (payload && payload.sub) { - return { - id: payload.sub, - name: payload.name, - email: payload.email, - image: payload.picture, - } - } - } else { - const userAndSession = await adapter?.getSessionAndUser(sessionToken) - if (userAndSession) { - return userAndSession.user - } + if (session.isDatabase) { + const userAndSession = await adapter?.getSessionAndUser(sessionCookie) + if (userAndSession) return userAndSession.user } - return null + const payload = await session.unseal(sessionCookie) + + if (!payload?.sub) return null + + return { + id: payload.sub, + name: payload.name, + email: payload.email, + image: payload.picture, + } } diff --git a/packages/core/src/lib/utils/webauthn-utils.ts b/packages/core/src/lib/utils/webauthn-utils.ts index c0d8119bf0..2b457a7149 100644 --- a/packages/core/src/lib/utils/webauthn-utils.ts +++ b/packages/core/src/lib/utils/webauthn-utils.ts @@ -3,7 +3,7 @@ import type { Account, Authenticator, Awaited, - InternalOptions, + InternalConfig, RequestInternal, ResponseInternal, User, @@ -39,7 +39,7 @@ export type WebAuthnRegister = "register" export type WebAuthnAuthenticate = "authenticate" export type WebAuthnAction = WebAuthnRegister | WebAuthnAuthenticate -type InternalOptionsWebAuthn = InternalOptions & { +type InternalOptionsWebAuthn = InternalConfig & { adapter: Required } export type WebAuthnOptionsResponseBody = @@ -318,11 +318,10 @@ export async function verifyAuthenticate( } export async function verifyRegister( - options: InternalOptions, request: RequestInternal, - resCookies: Cookie[] + config: InternalConfig ): Promise<{ account: Account; user: User; authenticator: Authenticator }> { - const { provider } = options + const { provider, resCookies } = config // Get WebAuthn response from request body const data = @@ -340,7 +339,7 @@ export async function verifyRegister( // Get challenge from request cookies const { challenge: expectedChallenge, registerData: user } = - await webauthnChallenge.use(options, request.cookies, resCookies) + await webauthnChallenge.use(config, request.cookies, resCookies) if (!user) { throw new AuthError( "Missing user registration data in WebAuthn challenge cookie" @@ -350,7 +349,7 @@ export async function verifyRegister( // Verify the response let verification: VerifiedRegistrationResponse try { - const relayingParty = provider.getRelayingParty(options, request) + const relayingParty = provider.getRelayingParty(config, request) verification = await provider.simpleWebAuthn.verifyRegistrationResponse({ ...provider.verifyRegistrationOptions, expectedChallenge, @@ -372,7 +371,7 @@ export async function verifyRegister( // Build a new account const account = { providerAccountId: toBase64(verification.registrationInfo.credentialID), - provider: options.provider.id, + provider: config.provider.id, type: provider.type, } @@ -480,9 +479,9 @@ async function getRegistrationOptions( } export function assertInternalOptionsWebAuthn( - options: InternalOptions + config: InternalConfig ): InternalOptionsWebAuthn { - const { provider, adapter } = options + const { provider, adapter } = config // Adapter is required for WebAuthn if (!adapter) @@ -492,7 +491,7 @@ export function assertInternalOptionsWebAuthn( throw new InvalidProvider("Provider must be WebAuthn") } // Narrow the options type for typed usage later - return { ...options, provider, adapter } + return { ...config, provider, adapter } } function fromAdapterAuthenticator( diff --git a/packages/core/src/providers/webauthn.ts b/packages/core/src/providers/webauthn.ts index 2cbc56bd38..45f8fbb0a2 100644 --- a/packages/core/src/providers/webauthn.ts +++ b/packages/core/src/providers/webauthn.ts @@ -15,7 +15,7 @@ import type { } from "@simplewebauthn/server" import type { - InternalOptions, + InternalConfig, RequestInternal, SemverString, User, @@ -45,7 +45,7 @@ type RelayingPartyArray = { } export type GetUserInfo = ( - options: InternalOptions, + config: InternalConfig, request: RequestInternal ) => Promise< | { user: User; exists: true } @@ -92,7 +92,7 @@ export interface WebAuthnConfig extends CommonProviderOptions { * Function that returns the relaying party for the current request. */ getRelayingParty: ( - options: InternalOptions, + config: InternalConfig, request: RequestInternal ) => RelayingParty /** @@ -266,9 +266,9 @@ const getUserInfo: GetUserInfo = async (options, request) => { */ function getRelayingParty( /** The options object containing the provider and URL information. */ - options: InternalOptions + config: InternalConfig ): RelayingParty { - const { provider, url } = options + const { provider, url } = config const { relayingParty } = provider const id = Array.isArray(relayingParty?.id) diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 4968eb9323..137338a61d 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -55,8 +55,7 @@ import type { SerializeOptions } from "./lib/vendored/cookie.js" import type { TokenEndpointResponse } from "oauth4webapi" import type { Adapter } from "./adapters.js" import { AuthConfig } from "./index.js" -import type { JWTOptions } from "./jwt.js" -import type { Cookie } from "./lib/utils/cookie.js" +import type { Cookie, SessionStore } from "./lib/utils/cookie.js" import type { LoggerInstance } from "./lib/utils/logger.js" import type { CredentialsConfig, @@ -69,6 +68,7 @@ import type { WebAuthnConfig, WebAuthnProviderType, } from "./providers/webauthn.js" +import { JWT } from "./jwt.js" export type { WebAuthnOptionsResponseBody } from "./lib/utils/webauthn-utils.js" export type { AuthConfig } from "./index.js" @@ -222,8 +222,8 @@ export interface PagesOptions { * * @default "/signin" */ - signIn: string - signOut: string + signIn?: string + signOut?: string /** * The path to the error page. * @@ -232,10 +232,10 @@ export interface PagesOptions { * * @default "/error" */ - error: string - verifyRequest: string + error?: string + verifyRequest?: string /** If set, new users will be directed here on first sign in */ - newUser: string + newUser?: string } type ISODateString = string @@ -394,7 +394,7 @@ export interface Authenticator { } /** @internal */ -export interface InternalOptions { +export interface InternalConfig { providers: InternalProvider[] url: URL action: AuthAction @@ -407,11 +407,18 @@ export interface InternalOptions { csrfTokenVerified?: boolean secret: string | string[] theme: Theme - debug: boolean logger: LoggerInstance - session: NonNullable> + session: { + isDatabase: boolean + unseal(value: string): Awaitable + seal(payload: JWT): Awaitable + generateSessionToken: NonNullable< + Required + >["generateSessionToken"] + maxAge: NonNullable>["maxAge"] + updateAge: NonNullable>["updateAge"] + } pages: Partial - jwt: JWTOptions events: NonNullable adapter: Required | undefined callbacks: NonNullable> @@ -424,4 +431,7 @@ export interface InternalOptions { isOnRedirectProxy: boolean experimental: NonNullable basePath: string + /** These set of cookies should be serialized and sent to the client as `Set-Cookie` headers */ + resCookies: Cookie[] + sessionStore: SessionStore }