diff --git a/packages/next-auth/src/core/lib/oauth/authorization-url.ts b/packages/next-auth/src/core/lib/oauth/authorization-url.ts index 481a8f61e3..0fc022f9b3 100644 --- a/packages/next-auth/src/core/lib/oauth/authorization-url.ts +++ b/packages/next-auth/src/core/lib/oauth/authorization-url.ts @@ -1,8 +1,6 @@ import { openidClient } from "./client" import { oAuth1Client, oAuth1TokenStore } from "./client-legacy" -import { createState } from "./state-handler" -import { createNonce } from "./nonce-handler" -import { createPKCE } from "./pkce-handler" +import * as checks from "./checks" import type { AuthorizationParameters } from "openid-client" import type { InternalOptions } from "../../types" @@ -54,24 +52,9 @@ export default async function getAuthorizationUrl({ const authorizationParams: AuthorizationParameters = params const cookies: Cookie[] = [] - const state = await createState(options) - if (state) { - authorizationParams.state = state.value - cookies.push(state.cookie) - } - - const nonce = await createNonce(options) - if (nonce) { - authorizationParams.nonce = nonce.value - cookies.push(nonce.cookie) - } - - const pkce = await createPKCE(options) - if (pkce) { - authorizationParams.code_challenge = pkce.code_challenge - authorizationParams.code_challenge_method = pkce.code_challenge_method - cookies.push(pkce.cookie) - } + await checks.state.create(options, cookies, authorizationParams) + await checks.pkce.create(options, cookies, authorizationParams) + await checks.nonce.create(options, cookies, authorizationParams) const url = client.authorizationUrl(authorizationParams) diff --git a/packages/next-auth/src/core/lib/oauth/callback.ts b/packages/next-auth/src/core/lib/oauth/callback.ts index 86d6a42d21..3038150bc8 100644 --- a/packages/next-auth/src/core/lib/oauth/callback.ts +++ b/packages/next-auth/src/core/lib/oauth/callback.ts @@ -1,12 +1,10 @@ import { TokenSet } from "openid-client" import { openidClient } from "./client" import { oAuth1Client, oAuth1TokenStore } from "./client-legacy" -import { useState } from "./state-handler" -import { usePKCECodeVerifier } from "./pkce-handler" -import { useNonce } from "./nonce-handler" +import * as _checks from "./checks" import { OAuthCallbackError } from "../../errors" -import type { CallbackParamsType, OpenIDCallbackChecks } from "openid-client" +import type { CallbackParamsType } from "openid-client" import type { LoggerInstance, Profile } from "../../.." import type { OAuthChecks, OAuthConfig } from "../../../providers" import type { InternalOptions } from "../../types" @@ -73,24 +71,9 @@ export default async function oAuthCallback(params: { const checks: OAuthChecks = {} const resCookies: Cookie[] = [] - const state = await useState(cookies?.[options.cookies.state.name], options) - if (state) { - checks.state = state.value - resCookies.push(state.cookie) - } - - const nonce = await useNonce(cookies?.[options.cookies.nonce.name], options) - if (nonce && provider.idToken) { - ;(checks as OpenIDCallbackChecks).nonce = nonce.value - resCookies.push(nonce.cookie) - } - - const codeVerifier = cookies?.[options.cookies.pkceCodeVerifier.name] - const pkce = await usePKCECodeVerifier(codeVerifier, options) - if (pkce) { - checks.code_verifier = pkce.codeVerifier - resCookies.push(pkce.cookie) - } + await _checks.state.use(cookies, resCookies, options, checks) + await _checks.pkce.use(cookies, resCookies, options, checks) + await _checks.nonce.use(cookies, resCookies, options, checks) const params: CallbackParamsType = { ...client.callbackParams({ diff --git a/packages/next-auth/src/core/lib/oauth/checks.ts b/packages/next-auth/src/core/lib/oauth/checks.ts new file mode 100644 index 0000000000..37d5a84be3 --- /dev/null +++ b/packages/next-auth/src/core/lib/oauth/checks.ts @@ -0,0 +1,181 @@ +import { + AuthorizationParameters, + generators, + OpenIDCallbackChecks, +} from "openid-client" +import * as jwt from "../../../jwt" + +import type { RequestInternal } from "../.." +import type { CookiesOptions, InternalOptions } from "../../types" +import type { Cookie } from "../cookie" +import { OAuthChecks } from "src/providers" + +/** Returns a signed cookie. */ +export async function signCookie( + type: keyof CookiesOptions, + value: string, + maxAge: number, + options: InternalOptions<"oauth"> +): Promise { + const { cookies, logger } = options + + logger.debug(`CREATE_${type.toUpperCase()}`, { value, maxAge }) + + const expires = new Date() + expires.setTime(expires.getTime() + maxAge * 1000) + return { + name: cookies[type].name, + value: await jwt.encode({ ...options.jwt, maxAge, token: { value } }), + options: { ...cookies[type].options, expires }, + } +} + +const PKCE_MAX_AGE = 60 * 15 // 15 minutes in seconds +export const PKCE_CODE_CHALLENGE_METHOD = "S256" +export const pkce = { + async create( + options: InternalOptions<"oauth">, + cookies: Cookie[], + resParams: AuthorizationParameters + ) { + if (!options.provider?.checks?.includes("pkce")) return + const code_verifier = generators.codeVerifier() + const value = generators.codeChallenge(code_verifier) + resParams.code_challenge = value + resParams.code_challenge_method = PKCE_CODE_CHALLENGE_METHOD + + const maxAge = + options.cookies.pkceCodeVerifier.options.maxAge ?? PKCE_MAX_AGE + + cookies.push( + await signCookie("pkceCodeVerifier", code_verifier, maxAge, options) + ) + }, + /** + * Returns code_verifier if the provider is configured to use PKCE, + * and clears the container cookie afterwards. + * An error is thrown if the code_verifier is missing or invalid. + * @see https://www.rfc-editor.org/rfc/rfc7636 + * @see https://danielfett.de/2020/05/16/pkce-vs-nonce-equivalent-or-not/#pkce + */ + async use( + cookies: RequestInternal["cookies"], + resCookies: Cookie[], + options: InternalOptions<"oauth">, + checks: OAuthChecks + ): Promise { + if (!options.provider?.checks?.includes("pkce")) return + + const codeVerifier = cookies?.[options.cookies.pkceCodeVerifier.name] + + if (!codeVerifier) + throw new TypeError("PKCE code_verifier cookie was missing.") + + const value = (await jwt.decode({ + ...options.jwt, + token: codeVerifier, + })) as any + + if (!value?.value) + throw new TypeError("PKCE code_verifier value could not be parsed.") + + resCookies.push({ + name: options.cookies.pkceCodeVerifier.name, + value: "", + options: { ...options.cookies.pkceCodeVerifier.options, maxAge: 0 }, + }) + + checks.code_verifier = value.value + }, +} + +const STATE_MAX_AGE = 60 * 15 // 15 minutes in seconds +export const state = { + async create( + options: InternalOptions<"oauth">, + cookies: Cookie[], + resParams: AuthorizationParameters + ) { + if (!options.provider.checks?.includes("state")) return + const value = generators.state() + resParams.state = value + const maxAge = options.cookies.state.options.maxAge ?? STATE_MAX_AGE + cookies.push(await signCookie("state", value, maxAge, options)) + }, + /** + * Returns state if the provider is configured to use state, + * and clears the container cookie afterwards. + * An error is thrown if the state is missing or invalid. + * @see https://www.rfc-editor.org/rfc/rfc6749#section-10.12 + * @see https://www.rfc-editor.org/rfc/rfc6749#section-4.1.1 + */ + async use( + cookies: RequestInternal["cookies"], + resCookies: Cookie[], + options: InternalOptions<"oauth">, + checks: OAuthChecks + ) { + if (!options.provider.checks?.includes("state")) return + + const state = cookies?.[options.cookies.state.name] + + if (!state) throw new TypeError("State cookie was missing.") + + const value = (await jwt.decode({ ...options.jwt, token: state })) as any + + if (!value?.value) throw new TypeError("State value could not be parsed.") + + resCookies.push({ + name: options.cookies.state.name, + value: "", + options: { ...options.cookies.state.options, maxAge: 0 }, + }) + + checks.state = value.value + }, +} + +const NONCE_MAX_AGE = 60 * 15 // 15 minutes in seconds +export const nonce = { + async create( + options: InternalOptions<"oauth">, + cookies: Cookie[], + resParams: AuthorizationParameters + ) { + if (!options.provider.checks?.includes("nonce")) return + const value = generators.nonce() + resParams.nonce = value + const maxAge = options.cookies.nonce.options.maxAge ?? NONCE_MAX_AGE + cookies.push(await signCookie("nonce", value, maxAge, options)) + }, + /** + * Returns nonce if the provider is configured to use nonce, + * and clears the container cookie afterwards. + * An error is thrown if the nonce is missing or invalid. + * @see https://openid.net/specs/openid-connect-core-1_0.html#NonceNotes + * @see https://danielfett.de/2020/05/16/pkce-vs-nonce-equivalent-or-not/#nonce + */ + async use( + cookies: RequestInternal["cookies"], + resCookies: Cookie[], + options: InternalOptions<"oauth">, + checks: OpenIDCallbackChecks + ): Promise { + if (!options.provider?.checks?.includes("nonce")) return + + const nonce = cookies?.[options.cookies.nonce.name] + if (!nonce) throw new TypeError("Nonce cookie was missing.") + + const value = (await jwt.decode({ ...options.jwt, token: nonce })) as any + + if (!value?.value) throw new TypeError("Nonce value could not be parsed.") + + resCookies.push({ + name: options.cookies.nonce.name, + value: "", + options: { ...options.cookies.nonce.options, maxAge: 0 }, + }) + + checks.nonce = value.value + }, +} diff --git a/packages/next-auth/src/core/lib/oauth/nonce-handler.ts b/packages/next-auth/src/core/lib/oauth/nonce-handler.ts deleted file mode 100644 index cc7aab362f..0000000000 --- a/packages/next-auth/src/core/lib/oauth/nonce-handler.ts +++ /dev/null @@ -1,75 +0,0 @@ -import * as jwt from "../../../jwt" -import { generators } from "openid-client" -import type { InternalOptions } from "../../types" -import type { Cookie } from "../cookie" - -const NONCE_MAX_AGE = 60 * 15 // 15 minutes in seconds - -/** - * Returns nonce if the provider supports it - * and saves it in a cookie */ -export async function createNonce(options: InternalOptions<"oauth">): Promise< - | undefined - | { - value: string - cookie: Cookie - } -> { - const { cookies, logger, provider } = options - if (!provider.checks?.includes("nonce")) { - // Provider does not support nonce, return nothing. - return - } - - const nonce = generators.nonce() - - const expires = new Date() - expires.setTime(expires.getTime() + NONCE_MAX_AGE * 1000) - - // Encrypt nonce and save it to an encrypted cookie - const encryptedNonce = await jwt.encode({ - ...options.jwt, - maxAge: NONCE_MAX_AGE, - token: { nonce }, - }) - - logger.debug("CREATE_ENCRYPTED_NONCE", { - nonce, - maxAge: NONCE_MAX_AGE, - }) - - return { - cookie: { - name: cookies.nonce.name, - value: encryptedNonce, - options: { ...cookies.nonce.options, expires }, - }, - value: nonce, - } -} - -/** - * Returns nonce from if the provider supports nonce, - * and clears the container cookie afterwards. - */ -export async function useNonce( - nonce: string | undefined, - options: InternalOptions<"oauth"> -): Promise<{ value: string; cookie: Cookie } | undefined> { - const { cookies, provider } = options - - if (!provider?.checks?.includes("nonce") || !nonce) { - return - } - - const value = (await jwt.decode({...options.jwt, token: nonce })) as any - - return { - value: value?.nonce ?? undefined, - cookie: { - name: cookies.nonce.name, - value: "", - options: { ...cookies.nonce.options, maxAge: 0 }, - }, - } -} diff --git a/packages/next-auth/src/core/lib/oauth/pkce-handler.ts b/packages/next-auth/src/core/lib/oauth/pkce-handler.ts deleted file mode 100644 index 0e279055ef..0000000000 --- a/packages/next-auth/src/core/lib/oauth/pkce-handler.ts +++ /dev/null @@ -1,86 +0,0 @@ -import * as jwt from "../../../jwt" -import { generators } from "openid-client" -import type { InternalOptions } from "../../types" -import type { Cookie } from "../cookie" - -const PKCE_CODE_CHALLENGE_METHOD = "S256" -const PKCE_MAX_AGE = 60 * 15 // 15 minutes in seconds - -/** - * Returns `code_challenge` and `code_challenge_method` - * and saves them in a cookie. - */ -export async function createPKCE(options: InternalOptions<"oauth">): Promise< - | undefined - | { - code_challenge: string - code_challenge_method: "S256" - cookie: Cookie - } -> { - const { cookies, logger, provider } = options - if (!provider.checks?.includes("pkce")) { - // Provider does not support PKCE, return nothing. - return - } - const code_verifier = generators.codeVerifier() - const code_challenge = generators.codeChallenge(code_verifier) - - const maxAge = cookies.pkceCodeVerifier.options.maxAge ?? PKCE_MAX_AGE - - const expires = new Date() - expires.setTime(expires.getTime() + maxAge * 1000) - - // Encrypt code_verifier and save it to an encrypted cookie - const encryptedCodeVerifier = await jwt.encode({ - ...options.jwt, - maxAge, - token: { code_verifier }, - }) - - logger.debug("CREATE_PKCE_CHALLENGE_VERIFIER", { - code_challenge, - code_challenge_method: PKCE_CODE_CHALLENGE_METHOD, - code_verifier, - maxAge, - }) - - return { - code_challenge, - code_challenge_method: PKCE_CODE_CHALLENGE_METHOD, - cookie: { - name: cookies.pkceCodeVerifier.name, - value: encryptedCodeVerifier, - options: { ...cookies.pkceCodeVerifier.options, expires }, - }, - } -} - -/** - * Returns code_verifier if provider uses PKCE, - * and clears the container cookie afterwards. - */ -export async function usePKCECodeVerifier( - codeVerifier: string | undefined, - options: InternalOptions<"oauth"> -): Promise<{ codeVerifier: string; cookie: Cookie } | undefined> { - const { cookies, provider } = options - - if (!provider?.checks?.includes("pkce") || !codeVerifier) { - return - } - - const pkce = (await jwt.decode({ - ...options.jwt, - token: codeVerifier, - })) as any - - return { - codeVerifier: pkce?.code_verifier ?? undefined, - cookie: { - name: cookies.pkceCodeVerifier.name, - value: "", - options: { ...cookies.pkceCodeVerifier.options, maxAge: 0 }, - }, - } -} diff --git a/packages/next-auth/src/core/lib/oauth/state-handler.ts b/packages/next-auth/src/core/lib/oauth/state-handler.ts deleted file mode 100644 index 80894f0de6..0000000000 --- a/packages/next-auth/src/core/lib/oauth/state-handler.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { generators } from "openid-client" - -import type { InternalOptions } from "../../types" -import type { Cookie } from "../cookie" - -const STATE_MAX_AGE = 60 * 15 // 15 minutes in seconds - -/** Returns state if the provider supports it */ -export async function createState( - options: InternalOptions<"oauth"> -): Promise<{ cookie: Cookie; value: string } | undefined> { - const { logger, provider, jwt, cookies } = options - - if (!provider.checks?.includes("state")) { - // Provider does not support state, return nothing - return - } - - const state = generators.state() - const maxAge = cookies.state.options.maxAge ?? STATE_MAX_AGE - - const encodedState = await jwt.encode({ - ...jwt, - maxAge, - token: { state }, - }) - - logger.debug("CREATE_STATE", { state, maxAge }) - - const expires = new Date() - expires.setTime(expires.getTime() + maxAge * 1000) - return { - value: state, - cookie: { - name: cookies.state.name, - value: encodedState, - options: { ...cookies.state.options, expires }, - }, - } -} - -/** - * Returns state from if the provider supports states, - * and clears the container cookie afterwards. - */ -export async function useState( - state: string | undefined, - options: InternalOptions<"oauth"> -): Promise<{ value: string; cookie: Cookie } | undefined> { - const { cookies, provider, jwt } = options - - if (!provider.checks?.includes("state")) return - - if (!state) throw new Error("No state provided") - - const value = (await jwt.decode({ ...options.jwt, token: state })) as any - - return { - value: value?.state ?? undefined, - cookie: { - name: cookies.state.name, - value: "", - options: { ...cookies.pkceCodeVerifier.options, maxAge: 0 }, - }, - } -}