diff --git a/.gitignore b/.gitignore index e114a25e02..ee626052ce 100644 --- a/.gitignore +++ b/.gitignore @@ -78,4 +78,11 @@ test.schema.gql # docusaurus docs/.docusaurus -docs/providers.json \ No newline at end of file +docs/providers.json + +# Core +packages/core/adapters.* +packages/core/index.* +packages/core/jwt +packages/core/lib +packages/core/providers \ No newline at end of file diff --git a/apps/dev/package.json b/apps/dev/package.json index 5b5cf51ed5..24b51e7fe1 100644 --- a/apps/dev/package.json +++ b/apps/dev/package.json @@ -23,6 +23,7 @@ "faunadb": "^4", "next": "13.0.6", "next-auth": "workspace:*", + "@auth/core": "workspace:*", "nodemailer": "^6", "react": "^18", "react-dom": "^18" diff --git a/apps/dev/pages/api/auth/[...nextauth].ts b/apps/dev/pages/api/auth/[...nextauth].ts index 10533891a6..ccdcc23ebd 100644 --- a/apps/dev/pages/api/auth/[...nextauth].ts +++ b/apps/dev/pages/api/auth/[...nextauth].ts @@ -1,39 +1,39 @@ -import NextAuth, { type NextAuthOptions } from "next-auth" +import { AuthHandler, type AuthOptions } from "@auth/core" // Providers -import Apple from "next-auth/providers/apple" -import Auth0 from "next-auth/providers/auth0" -import AzureAD from "next-auth/providers/azure-ad" -import AzureB2C from "next-auth/providers/azure-ad-b2c" -import BoxyHQSAML from "next-auth/providers/boxyhq-saml" -import Cognito from "next-auth/providers/cognito" -import Credentials from "next-auth/providers/credentials" -import Discord from "next-auth/providers/discord" -import DuendeIDS6 from "next-auth/providers/duende-identity-server6" -import Email from "next-auth/providers/email" -import Facebook from "next-auth/providers/facebook" -import Foursquare from "next-auth/providers/foursquare" -import Freshbooks from "next-auth/providers/freshbooks" -import GitHub from "next-auth/providers/github" -import Gitlab from "next-auth/providers/gitlab" -import Google from "next-auth/providers/google" -import IDS4 from "next-auth/providers/identity-server4" -import Instagram from "next-auth/providers/instagram" -import Keycloak from "next-auth/providers/keycloak" -import Line from "next-auth/providers/line" -import LinkedIn from "next-auth/providers/linkedin" -import Mailchimp from "next-auth/providers/mailchimp" -import Okta from "next-auth/providers/okta" -import Osu from "next-auth/providers/osu" -import Patreon from "next-auth/providers/patreon" -import Slack from "next-auth/providers/slack" -import Spotify from "next-auth/providers/spotify" -import Trakt from "next-auth/providers/trakt" -import Twitch from "next-auth/providers/twitch" -import Twitter, { TwitterLegacy } from "next-auth/providers/twitter" -import Vk from "next-auth/providers/vk" -import Wikimedia from "next-auth/providers/wikimedia" -import WorkOS from "next-auth/providers/workos" +import Apple from "@auth/core/providers/apple" +import Auth0 from "@auth/core/providers/auth0" +import AzureAD from "@auth/core/providers/azure-ad" +import AzureB2C from "@auth/core/providers/azure-ad-b2c" +import BoxyHQSAML from "@auth/core/providers/boxyhq-saml" +// import Cognito from "@auth/core/providers/cognito" +import Credentials from "@auth/core/providers/credentials" +import Discord from "@auth/core/providers/discord" +import DuendeIDS6 from "@auth/core/providers/duende-identity-server6" +// import Email from "@auth/core/providers/email" +import Facebook from "@auth/core/providers/facebook" +import Foursquare from "@auth/core/providers/foursquare" +import Freshbooks from "@auth/core/providers/freshbooks" +import GitHub from "@auth/core/providers/github" +import Gitlab from "@auth/core/providers/gitlab" +import Google from "@auth/core/providers/google" +// import IDS4 from "@auth/core/providers/identity-server4" +import Instagram from "@auth/core/providers/instagram" +// import Keycloak from "@auth/core/providers/keycloak" +import Line from "@auth/core/providers/line" +import LinkedIn from "@auth/core/providers/linkedin" +import Mailchimp from "@auth/core/providers/mailchimp" +// import Okta from "@auth/core/providers/okta" +import Osu from "@auth/core/providers/osu" +import Patreon from "@auth/core/providers/patreon" +import Slack from "@auth/core/providers/slack" +import Spotify from "@auth/core/providers/spotify" +import Trakt from "@auth/core/providers/trakt" +import Twitch from "@auth/core/providers/twitch" +import Twitter from "@auth/core/providers/twitter" +import Vk from "@auth/core/providers/vk" +import Wikimedia from "@auth/core/providers/wikimedia" +import WorkOS from "@auth/core/providers/workos" // // Prisma // import { PrismaClient } from "@prisma/client" @@ -66,9 +66,9 @@ import WorkOS from "next-auth/providers/workos" // secret: process.env.SUPABASE_SERVICE_ROLE_KEY, // }) -export const authOptions: NextAuthOptions = { +export const authOptions: AuthOptions = { // adapter, - debug: process.env.NODE_ENV !== "production", + // debug: process.env.NODE_ENV !== "production", theme: { logo: "https://next-auth.js.org/img/logo/logo-sm.png", brandColor: "#1786fb", @@ -78,15 +78,19 @@ export const authOptions: NextAuthOptions = { credentials: { password: { label: "Password", type: "password" } }, async authorize(credentials) { if (credentials.password !== "pw") return null - return { name: "Fill Murray", email: "bill@fillmurray.com", image: "https://www.fillmurray.com/64/64" } + return { name: "Fill Murray", email: "bill@fillmurray.com", image: "https://www.fillmurray.com/64/64", id: "1", foo: "" } }, }), Apple({ clientId: process.env.APPLE_ID, clientSecret: process.env.APPLE_SECRET }), Auth0({ clientId: process.env.AUTH0_ID, clientSecret: process.env.AUTH0_SECRET, issuer: process.env.AUTH0_ISSUER }), - AzureAD({ clientId: process.env.AZURE_AD_CLIENT_ID, clientSecret: process.env.AZURE_AD_CLIENT_SECRET, tenantId: process.env.AZURE_AD_TENANT_ID }), + AzureAD({ + clientId: process.env.AZURE_AD_CLIENT_ID, + clientSecret: process.env.AZURE_AD_CLIENT_SECRET, + tenantId: process.env.AZURE_AD_TENANT_ID, + }), AzureB2C({ clientId: process.env.AZURE_B2C_ID, clientSecret: process.env.AZURE_B2C_SECRET, issuer: process.env.AZURE_B2C_ISSUER }), BoxyHQSAML({ issuer: "https://jackson-demo.boxyhq.com", clientId: "tenant=boxyhq.com&product=saml-demo.boxyhq.com", clientSecret: "dummy" }), - Cognito({ clientId: process.env.COGNITO_ID, clientSecret: process.env.COGNITO_SECRET, issuer: process.env.COGNITO_ISSUER }), + // Cognito({ clientId: process.env.COGNITO_ID, clientSecret: process.env.COGNITO_SECRET, issuer: process.env.COGNITO_ISSUER }), Discord({ clientId: process.env.DISCORD_ID, clientSecret: process.env.DISCORD_SECRET }), DuendeIDS6({ clientId: "interactive.confidential", clientSecret: "secret", issuer: "https://demo.duendesoftware.com" }), Facebook({ clientId: process.env.FACEBOOK_ID, clientSecret: process.env.FACEBOOK_SECRET }), @@ -95,21 +99,21 @@ export const authOptions: NextAuthOptions = { GitHub({ clientId: process.env.GITHUB_ID, clientSecret: process.env.GITHUB_SECRET }), Gitlab({ clientId: process.env.GITLAB_ID, clientSecret: process.env.GITLAB_SECRET }), Google({ clientId: process.env.GOOGLE_ID, clientSecret: process.env.GOOGLE_SECRET }), - IDS4({ clientId: process.env.IDS4_ID, clientSecret: process.env.IDS4_SECRET, issuer: process.env.IDS4_ISSUER }), + // IDS4({ clientId: process.env.IDS4_ID, clientSecret: process.env.IDS4_SECRET, issuer: process.env.IDS4_ISSUER }), Instagram({ clientId: process.env.INSTAGRAM_ID, clientSecret: process.env.INSTAGRAM_SECRET }), - Keycloak({ clientId: process.env.KEYCLOAK_ID, clientSecret: process.env.KEYCLOAK_SECRET, issuer: process.env.KEYCLOAK_ISSUER }), + // Keycloak({ clientId: process.env.KEYCLOAK_ID, clientSecret: process.env.KEYCLOAK_SECRET, issuer: process.env.KEYCLOAK_ISSUER }), Line({ clientId: process.env.LINE_ID, clientSecret: process.env.LINE_SECRET }), LinkedIn({ clientId: process.env.LINKEDIN_ID, clientSecret: process.env.LINKEDIN_SECRET }), Mailchimp({ clientId: process.env.MAILCHIMP_ID, clientSecret: process.env.MAILCHIMP_SECRET }), - Okta({ clientId: process.env.OKTA_ID, clientSecret: process.env.OKTA_SECRET, issuer: process.env.OKTA_ISSUER }), + // Okta({ clientId: process.env.OKTA_ID, clientSecret: process.env.OKTA_SECRET, issuer: process.env.OKTA_ISSUER }), Osu({ clientId: process.env.OSU_CLIENT_ID, clientSecret: process.env.OSU_CLIENT_SECRET }), Patreon({ clientId: process.env.PATREON_ID, clientSecret: process.env.PATREON_SECRET }), Slack({ clientId: process.env.SLACK_ID, clientSecret: process.env.SLACK_SECRET }), Spotify({ clientId: process.env.SPOTIFY_ID, clientSecret: process.env.SPOTIFY_SECRET }), Trakt({ clientId: process.env.TRAKT_ID, clientSecret: process.env.TRAKT_SECRET }), Twitch({ clientId: process.env.TWITCH_ID, clientSecret: process.env.TWITCH_SECRET }), - Twitter({ version: "2.0", clientId: process.env.TWITTER_ID, clientSecret: process.env.TWITTER_SECRET }), - TwitterLegacy({ clientId: process.env.TWITTER_LEGACY_ID, clientSecret: process.env.TWITTER_LEGACY_SECRET }), + Twitter({ clientId: process.env.TWITTER_ID, clientSecret: process.env.TWITTER_SECRET }), + // TwitterLegacy({ clientId: process.env.TWITTER_LEGACY_ID, clientSecret: process.env.TWITTER_LEGACY_SECRET }), Vk({ clientId: process.env.VK_ID, clientSecret: process.env.VK_SECRET }), Wikimedia({ clientId: process.env.WIKIMEDIA_ID, clientSecret: process.env.WIKIMEDIA_SECRET }), WorkOS({ clientId: process.env.WORKOS_ID, clientSecret: process.env.WORKOS_SECRET }), @@ -117,11 +121,34 @@ export const authOptions: NextAuthOptions = { } if (authOptions.adapter) { - authOptions.providers.unshift( - // NOTE: You can start a fake e-mail server with `pnpm email` - // and then go to `http://localhost:1080` in the browser - Email({ server: "smtp://127.0.0.1:1025?tls.rejectUnauthorized=false" }) - ) + // TODO: + // authOptions.providers.unshift( + // // NOTE: You can start a fake e-mail server with `pnpm email` + // // and then go to `http://localhost:1080` in the browser + // Email({ server: "smtp://127.0.0.1:1025?tls.rejectUnauthorized=false" }) + // ) +} + +// TODO: move to next-auth/edge +function Auth(...args: any[]) { + const envSecret = process.env.AUTH_SECRET ?? process.env.NEXTAUTH_SECRET + const envTrustHost = !!(process.env.NEXTAUTH_URL ?? process.env.AUTH_TRUST_HOST ?? process.env.VERCEL ?? process.env.NODE_ENV !== "production") + if (args.length === 1) { + return async (req: Request) => { + args[0].secret ??= envSecret + args[0].trustHost ??= envTrustHost + return await AuthHandler(req, args[0]) + } + } + args[1].secret ??= envSecret + args[1].trustHost ??= envTrustHost + return AuthHandler(args[0], args[1]) +} + +// export default Auth(authOptions) + +export default function handle(request: Request) { + return Auth(request, authOptions) } -export default NextAuth(authOptions) +export const config = { runtime: "experimental-edge" } diff --git a/packages/core/package.json b/packages/core/package.json new file mode 100644 index 0000000000..72d8608b83 --- /dev/null +++ b/packages/core/package.json @@ -0,0 +1,75 @@ +{ + "name": "@auth/core", + "version": "0.0.0", + "description": "Authentication for the web.", + "homepage": "https://next-auth.js.org", + "repository": "https://github.com/nextauthjs/next-auth.git", + "author": "Balázs Orbán ", + "contributors": [ + "Balázs Orbán ", + "Nico Domino ", + "Lluis Agusti ", + "Thang Huu Vu ", + "Iain Collins = DefaultAdapter & + (WithVerificationToken extends true + ? { + createVerificationToken: ( + verificationToken: VerificationToken + ) => Awaitable + /** + * Return verification token from the database + * and delete it so it cannot be used again. + */ + useVerificationToken: (params: { + identifier: string + token: string + }) => Awaitable + } + : {}) + +export interface DefaultAdapter { + createUser: (user: Omit) => Awaitable + getUser: (id: string) => Awaitable + getUserByEmail: (email: string) => Awaitable + /** Using the provider id and the id of the user for a specific account, get the user. */ + getUserByAccount: ( + providerAccountId: Pick + ) => Awaitable + updateUser: (user: Partial) => Awaitable + /** @todo Implement */ + deleteUser?: ( + userId: string + ) => Promise | Awaitable + linkAccount: ( + account: AdapterAccount + ) => Promise | Awaitable + /** @todo Implement */ + unlinkAccount?: ( + providerAccountId: Pick + ) => Promise | Awaitable + /** Creates a session for the user and returns it. */ + createSession: (session: { + sessionToken: string + userId: string + expires: Date + }) => Awaitable + getSessionAndUser: ( + sessionToken: string + ) => Awaitable<{ session: AdapterSession; user: AdapterUser } | null> + updateSession: ( + session: Partial & Pick + ) => Awaitable + /** + * Deletes a session from the database. + * It is preferred that this method also returns the session + * that is being deleted for logging purposes. + */ + deleteSession: ( + sessionToken: string + ) => Promise | Awaitable + createVerificationToken?: ( + verificationToken: VerificationToken + ) => Awaitable + /** + * Return verification token from the database + * and delete it so it cannot be used again. + */ + useVerificationToken?: (params: { + identifier: string + token: string + }) => Awaitable +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts new file mode 100644 index 0000000000..39886a1594 --- /dev/null +++ b/packages/core/src/index.ts @@ -0,0 +1,287 @@ +import { init } from "./lib/init" +import { assertConfig } from "./lib/assert" +import { SessionStore } from "./lib/cookie" +import { toInternalRequest, toResponse } from "./lib/web" +import renderPage from "./lib/pages" +import * as routes from "./lib/routes" +import logger, { setLogger } from "./lib/utils/logger" + +import type { ErrorType } from "./lib/pages/error" +import type { + AuthOptions, + RequestInternal, + ResponseInternal, +} from "./lib/types" +import { UntrustedHost } from "./lib/errors" + +export * from "./lib/types" + +const configErrorMessage = + "There is a problem with the server configuration. Check the server logs for more information." + +async function AuthHandlerInternal< + Body extends string | Record | any[] +>(params: { + req: RequestInternal + options: AuthOptions + /** REVIEW: Is this the best way to skip parsing the body in Node.js? */ + parsedBody?: any +}): Promise> { + const { options: authOptions, req } = params + + const assertionResult = assertConfig({ options: authOptions, req }) + + if (Array.isArray(assertionResult)) { + assertionResult.forEach(logger.warn) + } else if (assertionResult instanceof Error) { + // Bail out early if there's an error in the user config + logger.error(assertionResult.code, assertionResult) + + const htmlPages = ["signin", "signout", "error", "verify-request"] + if (!htmlPages.includes(req.action) || req.method !== "GET") { + return { + status: 500, + headers: { "Content-Type": "application/json" }, + body: { message: configErrorMessage } as any, + } + } + const { pages, theme } = authOptions + + const authOnErrorPage = + pages?.error && req.query?.callbackUrl?.startsWith(pages.error) + + if (!pages?.error || authOnErrorPage) { + if (authOnErrorPage) { + logger.error( + "AUTH_ON_ERROR_PAGE_ERROR", + new Error( + `The error page ${pages?.error} should not require authentication` + ) + ) + } + const render = renderPage({ theme }) + return render.error({ error: "configuration" }) + } + + return { + redirect: `${pages.error}?error=Configuration`, + } + } + + const { action, providerId, error, method } = req + + const { options, cookies } = await init({ + authOptions, + action, + providerId, + url: req.url, + callbackUrl: req.body?.callbackUrl ?? req.query?.callbackUrl, + csrfToken: req.body?.csrfToken, + cookies: req.cookies, + isPost: method === "POST", + }) + + const sessionStore = new SessionStore( + options.cookies.sessionToken, + req, + options.logger + ) + + if (method === "GET") { + const render = renderPage({ ...options, query: req.query, cookies }) + const { pages } = options + switch (action) { + case "providers": + return (await routes.providers(options.providers)) as any + case "session": { + const session = await routes.session({ options, sessionStore }) + if (session.cookies) cookies.push(...session.cookies) + // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion + return { ...session, cookies } as any + } + case "csrf": + return { + headers: { "Content-Type": "application/json" }, + body: { csrfToken: options.csrfToken } as any, + cookies, + } + case "signin": + if (pages.signIn) { + let signinUrl = `${pages.signIn}${ + pages.signIn.includes("?") ? "&" : "?" + }callbackUrl=${encodeURIComponent(options.callbackUrl)}` + if (error) + signinUrl = `${signinUrl}&error=${encodeURIComponent(error)}` + return { redirect: signinUrl, cookies } + } + + return render.signin() + case "signout": + if (pages.signOut) return { redirect: pages.signOut, cookies } + + return render.signout() + case "callback": + if (options.provider) { + const callback = await routes.callback({ + body: req.body, + query: req.query, + headers: req.headers, + cookies: req.cookies, + method, + options, + sessionStore, + }) + if (callback.cookies) cookies.push(...callback.cookies) + return { ...callback, cookies } + } + break + case "verify-request": + if (pages.verifyRequest) { + return { redirect: pages.verifyRequest, cookies } + } + return render.verifyRequest() + case "error": + // These error messages are displayed in line on the sign in page + if ( + [ + "Signin", + "OAuthSignin", + "OAuthCallback", + "OAuthCreateAccount", + "EmailCreateAccount", + "Callback", + "OAuthAccountNotLinked", + "EmailSignin", + "CredentialsSignin", + "SessionRequired", + ].includes(error as string) + ) { + return { redirect: `${options.url}/signin?error=${error}`, cookies } + } + + if (pages.error) { + return { + redirect: `${pages.error}${ + pages.error.includes("?") ? "&" : "?" + }error=${error}`, + cookies, + } + } + + return render.error({ error: error as ErrorType }) + default: + } + } else if (method === "POST") { + switch (action) { + case "signin": + // Verified CSRF Token required for all sign in routes + if (options.csrfTokenVerified && options.provider) { + const signin = await routes.signin({ + query: req.query, + body: req.body, + options, + }) + if (signin.cookies) cookies.push(...signin.cookies) + return { ...signin, cookies } + } + + return { redirect: `${options.url}/signin?csrf=true`, cookies } + case "signout": + // Verified CSRF Token required for signout + if (options.csrfTokenVerified) { + const signout = await routes.signout({ options, sessionStore }) + if (signout.cookies) cookies.push(...signout.cookies) + return { ...signout, cookies } + } + return { redirect: `${options.url}/signout?csrf=true`, cookies } + case "callback": + if (options.provider) { + // Verified CSRF Token required for credentials providers only + if ( + options.provider.type === "credentials" && + !options.csrfTokenVerified + ) { + return { redirect: `${options.url}/signin?csrf=true`, cookies } + } + + const callback = await routes.callback({ + body: req.body, + query: req.query, + headers: req.headers, + cookies: req.cookies, + method, + options, + sessionStore, + }) + if (callback.cookies) cookies.push(...callback.cookies) + return { ...callback, cookies } + } + break + case "_log": + if (authOptions.logger) { + try { + const { code, level, ...metadata } = req.body ?? {} + logger[level](code, metadata) + } catch (error) { + // If logging itself failed... + logger.error("LOGGER_ERROR", error as Error) + } + } + return {} + default: + } + } + + return { + status: 400, + body: `Error: This action with HTTP ${method} is not supported by NextAuth.js` as any, + } +} + +/** + * The core functionality of Auth.js. + * It receives a standard [`Request`](https://developer.mozilla.org/en-US/docs/Web/API/Request) + * and returns a standard [`Response`](https://developer.mozilla.org/en-US/docs/Web/API/Response). + */ +export async function AuthHandler( + request: Request, + options: AuthOptions +): Promise { + setLogger(options.logger, options.debug) + + if (!options.trustHost) { + const error = new UntrustedHost( + `Host must be trusted. URL was: ${request.url}` + ) + logger.error(error.code, error) + + return new Response(JSON.stringify({ message: configErrorMessage }), { + status: 500, + headers: { "Content-Type": "application/json" }, + }) + } + + const req = await toInternalRequest(request) + if (req instanceof Error) { + logger.error((req as any).code, req) + return new Response( + `Error: This action with HTTP ${request.method} is not supported.`, + { status: 400 } + ) + } + const internalResponse = await AuthHandlerInternal({ req, options }) + + const response = await toResponse(internalResponse) + + // If the request expects a return URL, send it as JSON + // instead of doing an actual redirect. + const redirect = response.headers.get("Location") + if (request.headers.has("X-Auth-Return-Redirect") && redirect) { + response.headers.delete("Location") + response.headers.set("Content-Type", "application/json") + return new Response(JSON.stringify({ url: redirect }), { + headers: response.headers, + }) + } + return response +} diff --git a/packages/core/src/jwt/index.ts b/packages/core/src/jwt/index.ts new file mode 100644 index 0000000000..323d8eee6f --- /dev/null +++ b/packages/core/src/jwt/index.ts @@ -0,0 +1,127 @@ +import { EncryptJWT, jwtDecrypt } from "jose" +import hkdf from "@panva/hkdf" +import { SessionStore } from "../lib/cookie" +import type { JWT, JWTDecodeParams, JWTEncodeParams, JWTOptions } from "./types" +import type { LoggerInstance } from ".." + +export * from "./types" + +const DEFAULT_MAX_AGE = 30 * 24 * 60 * 60 // 30 days + +const now = () => (Date.now() / 1000) | 0 + +/** Issues a JWT. By default, the JWT is encrypted using "A256GCM". */ +export async function encode(params: JWTEncodeParams) { + const { token = {}, secret, maxAge = DEFAULT_MAX_AGE } = params + const encryptionSecret = await getDerivedEncryptionKey(secret) + return await new EncryptJWT(token) + .setProtectedHeader({ alg: "dir", enc: "A256GCM" }) + .setIssuedAt() + .setExpirationTime(now() + maxAge) + .setJti(crypto.randomUUID()) + .encrypt(encryptionSecret) +} + +/** Decodes a NextAuth.js issued JWT. */ +export async function decode(params: JWTDecodeParams): Promise { + const { token, secret } = params + if (!token) return null + const encryptionSecret = await getDerivedEncryptionKey(secret) + const { payload } = await jwtDecrypt(token, encryptionSecret, { + clockTolerance: 15, + }) + return payload +} + +export interface GetTokenParams { + /** The request containing the JWT either in the cookies or in the `Authorization` header. */ + req: + | Request + | { cookies: Record; headers: Record } + /** + * Use secure prefix for cookie name, unless URL in `NEXTAUTH_URL` is http:// + * or not set (e.g. development or test instance) case use unprefixed name + */ + secureCookie?: boolean + /** If the JWT is in the cookie, what name `getToken()` should look for. */ + cookieName?: string + /** + * `getToken()` will return the raw JWT if this is set to `true` + * @default false + */ + raw?: R + /** + * The same `secret` used in the `NextAuth` configuration. + * Defaults to the `NEXTAUTH_SECRET` environment variable. + */ + secret?: string + decode?: JWTOptions["decode"] + logger?: LoggerInstance | Console +} + +/** + * Takes a NextAuth.js request (`req`) and returns either the NextAuth.js issued JWT's payload, + * or the raw JWT string. We look for the JWT in the either the cookies, or the `Authorization` header. + * [Documentation](https://next-auth.js.org/tutorials/securing-pages-and-api-routes#using-gettoken) + */ +export async function getToken( + params: GetTokenParams +): Promise { + const { + req, + secureCookie = process.env.NEXTAUTH_URL?.startsWith("https://") ?? + !!process.env.VERCEL, + cookieName = secureCookie + ? "__Secure-next-auth.session-token" + : "next-auth.session-token", + raw, + decode: _decode = decode, + logger = console, + secret = process.env.NEXTAUTH_SECRET, + } = params + + if (!req) throw new Error("Must pass `req` to JWT getToken()") + + const sessionStore = new SessionStore( + { name: cookieName, options: { secure: secureCookie } }, + // @ts-expect-error + { cookies: req.cookies, headers: req.headers }, + logger + ) + + let token = sessionStore.value + + const authorizationHeader = + req.headers instanceof Headers + ? req.headers.get("authorization") + : req.headers.authorization + + if (!token && authorizationHeader?.split(" ")[0] === "Bearer") { + const urlEncodedToken = authorizationHeader.split(" ")[1] + token = decodeURIComponent(urlEncodedToken) + } + + // @ts-expect-error + if (!token) return null + + // @ts-expect-error + if (raw) return token + + try { + // @ts-expect-error + return await _decode({ token, secret }) + } catch { + // @ts-expect-error + return null + } +} + +async function getDerivedEncryptionKey(secret: string | Buffer) { + return await hkdf( + "sha256", + secret, + "", + "NextAuth.js Generated Encryption Key", + 32 + ) +} diff --git a/packages/core/src/jwt/types.ts b/packages/core/src/jwt/types.ts new file mode 100644 index 0000000000..ecd0e2a9b0 --- /dev/null +++ b/packages/core/src/jwt/types.ts @@ -0,0 +1,54 @@ +import type { Awaitable } from ".." + +export interface DefaultJWT extends Record { + name?: string | null + email?: string | null + picture?: string | null + sub?: string +} + +/** + * Returned by the `jwt` callback and `getToken`, when using JWT sessions + * + * [`jwt` callback](https://next-auth.js.org/configuration/callbacks#jwt-callback) | [`getToken`](https://next-auth.js.org/tutorials/securing-pages-and-api-routes#using-gettoken) + */ +export interface JWT extends Record, DefaultJWT {} + +export interface JWTEncodeParams { + /** The JWT payload. */ + token?: JWT + /** The secret used to encode the NextAuth.js issued JWT. */ + secret: string | Buffer + /** + * The maximum age of the NextAuth.js issued JWT in seconds. + * @default 30 * 24 * 30 * 60 // 30 days + */ + maxAge?: number +} + +export interface JWTDecodeParams { + /** The NextAuth.js issued JWT to be decoded */ + token?: string + /** The secret used to decode the NextAuth.js issued JWT. */ + secret: string | Buffer +} + +export interface JWTOptions { + /** + * The secret used to encode/decode the NextAuth.js issued JWT. + * @deprecated Set the `NEXTAUTH_SECRET` environment vairable or + * use the top-level `secret` option instead + */ + secret: string + /** + * The maximum age of the NextAuth.js issued JWT in seconds. + * @default 30 * 24 * 30 * 60 // 30 days + */ + maxAge: number + /** Override this method to control the NextAuth.js issued JWT encoding. */ + encode: (params: JWTEncodeParams) => Awaitable + /** Override this method to control the NextAuth.js issued JWT decoding. */ + decode: (params: JWTDecodeParams) => Awaitable +} + +export type Secret = string | Buffer diff --git a/packages/core/src/lib/assert.ts b/packages/core/src/lib/assert.ts new file mode 100644 index 0000000000..16c75eb68f --- /dev/null +++ b/packages/core/src/lib/assert.ts @@ -0,0 +1,157 @@ +import { + InvalidCallbackUrl, + InvalidEndpoints, + MissingAdapter, + MissingAdapterMethods, + MissingAPIRoute, + MissingAuthorize, + MissingSecret, + UnsupportedStrategy, +} from "./errors" +import { defaultCookies } from "./cookie" + +import type { AuthOptions, RequestInternal } from ".." +import type { WarningCode } from "./utils/logger" + +type ConfigError = + | MissingAdapter + | MissingAdapterMethods + | MissingAPIRoute + | MissingAuthorize + | MissingSecret + | InvalidCallbackUrl + | UnsupportedStrategy + | InvalidEndpoints + | UnsupportedStrategy + +let warned = false + +function isValidHttpUrl(url: string, baseUrl: string) { + try { + return /^https?:/.test( + new URL(url, url.startsWith("/") ? baseUrl : undefined).protocol + ) + } catch { + return false + } +} + +/** + * Verify that the user configured Auth.js correctly. + * Good place to mention deprecations as well. + * + * REVIEW: Make some of these and corresponding docs less Next.js specific? + */ +export function assertConfig(params: { + options: AuthOptions + req: RequestInternal +}): ConfigError | WarningCode[] { + const { options, req } = params + const { url } = req + const warnings: WarningCode[] = [] + + if (!warned) { + if (!url.origin) warnings.push("NEXTAUTH_URL") + if (options.debug) warnings.push("DEBUG_ENABLED") + } + + if (!options.secret) { + return new MissingSecret("Please define a `secret` in production.") + } + + // req.query isn't defined when asserting `unstable_getServerSession` for example + if (!req.query?.nextauth && !req.action) { + return new MissingAPIRoute( + "Cannot find [...nextauth].{js,ts} in `/pages/api/auth`. Make sure the filename is written correctly." + ) + } + + const callbackUrlParam = req.query?.callbackUrl as string | undefined + + if (callbackUrlParam && !isValidHttpUrl(callbackUrlParam, url.origin)) { + return new InvalidCallbackUrl( + `Invalid callback URL. Received: ${callbackUrlParam}` + ) + } + + const { callbackUrl: defaultCallbackUrl } = defaultCookies( + options.useSecureCookies ?? url.protocol === "https://" + ) + const callbackUrlCookie = + req.cookies?.[options.cookies?.callbackUrl?.name ?? defaultCallbackUrl.name] + + if (callbackUrlCookie && !isValidHttpUrl(callbackUrlCookie, url.origin)) { + return new InvalidCallbackUrl( + `Invalid callback URL. Received: ${callbackUrlCookie}` + ) + } + + let hasCredentials, hasEmail + + for (const provider of options.providers) { + if ( + (provider.type === "oauth" || provider.type === "oidc") && + !(provider.issuer ?? provider.options?.issuer) + ) { + const { authorization: a, token: t, userinfo: u } = provider + + let key + if (typeof a !== "string" && !a?.url) key = "authorization" + else if (typeof t !== "string" && !t?.url) key = "token" + else if (typeof u !== "string" && !u?.url) key = "userinfo" + + if (key) { + return new InvalidEndpoints( + `Provider "${provider.id}" is missing both \`issuer\` and \`${key}\` endpoint config. At least one of them is required.` + ) + } + } + + if (provider.type === "credentials") hasCredentials = true + else if (provider.type === "email") hasEmail = true + } + + if (hasCredentials) { + const dbStrategy = options.session?.strategy === "database" + const onlyCredentials = !options.providers.some( + (p) => p.type !== "credentials" + ) + if (dbStrategy && onlyCredentials) { + return new UnsupportedStrategy( + "Signin in with credentials only supported if JWT strategy is enabled" + ) + } + + const credentialsNoAuthorize = options.providers.some( + (p) => p.type === "credentials" && !p.authorize + ) + if (credentialsNoAuthorize) { + return new MissingAuthorize( + "Must define an authorize() handler to use credentials authentication provider" + ) + } + } + + if (hasEmail) { + const { adapter } = options + if (!adapter) { + return new MissingAdapter("E-mail login requires an adapter.") + } + + const missingMethods = [ + "createVerificationToken", + "useVerificationToken", + "getUserByEmail", + ].filter((method) => !adapter[method]) + + if (missingMethods.length) { + return new MissingAdapterMethods( + `Required adapter methods were missing: ${missingMethods.join(", ")}` + ) + } + } + + if (!warned) warned = true + + return warnings +} diff --git a/packages/core/src/lib/callback-handler.ts b/packages/core/src/lib/callback-handler.ts new file mode 100644 index 0000000000..76a4b03710 --- /dev/null +++ b/packages/core/src/lib/callback-handler.ts @@ -0,0 +1,228 @@ +import { AccountNotLinkedError } from "./errors" +import { fromDate } from "./utils/date" + +import type { Account, InternalOptions, User } from ".." +import type { AdapterSession, AdapterUser } from "../adapters" +import type { JWT } from "../jwt" +import type { OAuthConfig } from "../providers" +import type { SessionToken } from "./cookie" + +/** + * This function handles the complex flow of signing users in, and either creating, + * linking (or not linking) accounts depending on if the user is currently logged + * in, if they have account already and the authentication mechanism they are using. + * + * It prevents insecure behaviour, such as linking OAuth accounts unless a user is + * signed in and authenticated with an existing valid account. + * + * All verification (e.g. OAuth flows or email address verificaiton flows) are + * done prior to this handler being called to avoid additonal complexity in this + * handler. + */ +export default async function callbackHandler(params: { + sessionToken?: SessionToken + profile: User | AdapterUser | { email: string } + account: Account | null + options: InternalOptions +}) { + const { sessionToken, profile: _profile, account, options } = params + // Input validation + if (!account?.providerAccountId || !account.type) + throw new Error("Missing or invalid provider account") + if (!["email", "oauth", "oidc"].includes(account.type)) + throw new Error("Provider not supported") + + const { + adapter, + jwt, + events, + session: { strategy: sessionStrategy, generateSessionToken }, + } = options + + // 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. + if (!adapter) { + return { user: _profile as User, account } + } + + const profile = _profile as AdapterUser + + const { + createUser, + updateUser, + getUser, + getUserByAccount, + getUserByEmail, + linkAccount, + createSession, + getSessionAndUser, + deleteSession, + } = adapter + + let session: AdapterSession | JWT | null = null + let user: AdapterUser | null = null + let isNewUser = false + + const useJwtSession = sessionStrategy === "jwt" + + if (sessionToken) { + if (useJwtSession) { + try { + session = await jwt.decode({ ...jwt, token: sessionToken }) + if (session && "sub" in session && session.sub) { + user = await getUser(session.sub) + } + } catch { + // If session can't be verified, treat as no session + } + } else { + const userAndSession = await getSessionAndUser(sessionToken) + if (userAndSession) { + session = userAndSession.session + user = userAndSession.user + } + } + } + + if (account.type === "email") { + // If signing in with an email, check if an account with the same email address exists already + const userByEmail = await getUserByEmail(profile.email) + if (userByEmail) { + // If they are not already signed in as the same user, this flow will + // sign them out of the current session and sign them in as the new user + if (user?.id !== userByEmail.id && !useJwtSession && sessionToken) { + // Delete existing session if they are currently signed in as another user. + // This will switch user accounts for the session in cases where the user was + // already logged in with a different account. + await deleteSession(sessionToken) + } + + // Update emailVerified property on the user object + user = await updateUser({ id: userByEmail.id, emailVerified: new Date() }) + await events.updateUser?.({ user }) + } else { + const { id: _, ...newUser } = { ...profile, emailVerified: new Date() } + // Create user account if there isn't one for the email address already + user = await createUser(newUser) + await events.createUser?.({ user }) + isNewUser = true + } + + // Create new session + session = useJwtSession + ? {} + : await createSession({ + sessionToken: generateSessionToken(), + userId: user.id, + expires: fromDate(options.session.maxAge), + }) + + return { session, user, isNewUser } + } else if (account.type === "oauth") { + // If signing in with OAuth account, check to see if the account exists already + const userByAccount = await getUserByAccount({ + providerAccountId: account.providerAccountId, + provider: account.provider, + }) + if (userByAccount) { + if (user) { + // If the user is already signed in with this account, we don't need to do anything + if (userByAccount.id === user.id) { + return { session, user, isNewUser } + } + // If the user is currently signed in, but the new account they are signing in + // with is already associated with another user, then we cannot link them + // and need to return an error. + throw new AccountNotLinkedError( + "The account is already associated with another user" + ) + } + // If there is no active session, but the account being signed in with is already + // associated with a valid user then create session to sign the user in. + session = useJwtSession + ? {} + : await createSession({ + sessionToken: generateSessionToken(), + userId: userByAccount.id, + expires: fromDate(options.session.maxAge), + }) + + return { session, user: userByAccount, isNewUser } + } else { + if (user) { + // If the user is already signed in and the OAuth account isn't already associated + // with another user account then we can go ahead and link the accounts safely. + await linkAccount({ ...account, userId: user.id }) + await events.linkAccount?.({ user, account, profile }) + + // As they are already signed in, we don't need to do anything after linking them + return { session, user, isNewUser } + } + + // If the user is not signed in and it looks like a new OAuth account then we + // check there also isn't an user account already associated with the same + // email address as the one in the OAuth profile. + // + // This step is often overlooked in OAuth implementations, but covers the following cases: + // + // 1. It makes it harder for someone to accidentally create two accounts. + // e.g. by signin in with email, then again with an oauth account connected to the same email. + // 2. It makes it harder to hijack a user account using a 3rd party OAuth account. + // e.g. by creating an oauth account then changing the email address associated with it. + // + // It's quite common for services to automatically link accounts in this case, but it's + // better practice to require the user to sign in *then* link accounts to be sure + // someone is not exploiting a problem with a third party OAuth service. + // + // OAuth providers should require email address verification to prevent this, but in + // practice that is not always the case; this helps protect against that. + const userByEmail = profile.email + ? await getUserByEmail(profile.email) + : null + if (userByEmail) { + const provider = options.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. + user = userByEmail + } else { + // We end up here when we don't have an account with the same [provider].id *BUT* + // we do already have an account with the same email address as the one in the + // OAuth profile the user has just tried to sign in with. + // + // We don't want to have two accounts with the same email address, and we don't + // want to link them in case it's not safe to do so, so instead we prompt the user + // to sign in via email to verify their identity and then link the accounts. + throw new AccountNotLinkedError( + "Another account already exists with the same e-mail address" + ) + } + } else { + // If the current user is not logged in and the profile isn't linked to any user + // accounts (by email or provider account id)... + // + // If no account matching the same [provider].id or .email exists, we can + // create a new account for the user, link it to the OAuth acccount and + // create a new session for them so they are signed in with it. + const { id: _, ...newUser } = { ...profile, emailVerified: null } + user = await createUser(newUser) + } + await events.createUser?.({ user }) + + await linkAccount({ ...account, userId: user.id }) + await events.linkAccount?.({ user, account, profile }) + + session = useJwtSession + ? {} + : await createSession({ + sessionToken: generateSessionToken(), + userId: user.id, + expires: fromDate(options.session.maxAge), + }) + + return { session, user, isNewUser: true } + } + } + + throw new Error("Unsupported account type") +} diff --git a/packages/core/src/lib/callback-url.ts b/packages/core/src/lib/callback-url.ts new file mode 100644 index 0000000000..39c0692de6 --- /dev/null +++ b/packages/core/src/lib/callback-url.ts @@ -0,0 +1,42 @@ +import type { InternalOptions } from ".." + +interface CreateCallbackUrlParams { + options: InternalOptions + /** Try reading value from request body (POST) then from query param (GET) */ + paramValue?: string + cookieValue?: string +} + +/** + * Get callback URL based on query param / cookie + validation, + * and add it to `req.options.callbackUrl`. + */ +export async function createCallbackUrl({ + options, + paramValue, + cookieValue, +}: CreateCallbackUrlParams) { + const { url, callbacks } = options + + let callbackUrl = url.origin + + if (paramValue) { + // If callbackUrl form field or query parameter is passed try to use it if allowed + callbackUrl = await callbacks.redirect({ + url: paramValue, + baseUrl: url.origin, + }) + } else if (cookieValue) { + // If no callbackUrl specified, try using the value from the cookie if allowed + callbackUrl = await callbacks.redirect({ + url: cookieValue, + baseUrl: url.origin, + }) + } + + return { + callbackUrl, + // Save callback URL in a cookie so that it can be used for subsequent requests in signin/signout/callback flow + callbackUrlCookie: callbackUrl !== cookieValue ? callbackUrl : undefined, + } +} diff --git a/packages/core/src/lib/cookie.ts b/packages/core/src/lib/cookie.ts new file mode 100644 index 0000000000..e038a9e3f7 --- /dev/null +++ b/packages/core/src/lib/cookie.ts @@ -0,0 +1,236 @@ +import type { + CookieOption, + CookiesOptions, + LoggerInstance, + SessionStrategy, +} from ".." + +// Uncomment to recalculate the estimated size +// of an empty session cookie +// import { serialize } from "cookie" +// console.log( +// "Cookie estimated to be ", +// serialize(`__Secure.next-auth.session-token.0`, "", { +// expires: new Date(), +// httpOnly: true, +// maxAge: Number.MAX_SAFE_INTEGER, +// path: "/", +// sameSite: "strict", +// secure: true, +// domain: "example.com", +// }).length, +// " bytes" +// ) + +const ALLOWED_COOKIE_SIZE = 4096 +// Based on commented out section above +const ESTIMATED_EMPTY_COOKIE_SIZE = 163 +const CHUNK_SIZE = ALLOWED_COOKIE_SIZE - ESTIMATED_EMPTY_COOKIE_SIZE + +// REVIEW: Is there any way to defer two types of strings? + +/** Stringified form of `JWT`. Extract the content with `jwt.decode` */ +export type JWTString = string + +export type SetCookieOptions = Partial & { + expires?: Date | string + encode?: (val: unknown) => string +} + +/** + * If `options.session.strategy` is set to `jwt`, this is a stringified `JWT`. + * In case of `strategy: "database"`, this is the `sessionToken` of the session in the database. + */ +export type SessionToken = T extends "jwt" + ? JWTString + : string + +/** + * Use secure cookies if the site uses HTTPS + * This being conditional allows cookies to work non-HTTPS development URLs + * Honour secure cookie option, which sets 'secure' and also adds '__Secure-' + * prefix, but enable them by default if the site URL is HTTPS; but not for + * non-HTTPS URLs like http://localhost which are used in development). + * For more on prefixes see https://googlechrome.github.io/samples/cookie-prefixes/ + * + * @TODO Review cookie settings (names, options) + */ +export function defaultCookies(useSecureCookies: boolean): CookiesOptions { + const cookiePrefix = useSecureCookies ? "__Secure-" : "" + return { + // default cookie options + sessionToken: { + name: `${cookiePrefix}next-auth.session-token`, + options: { + httpOnly: true, + sameSite: "lax", + path: "/", + secure: useSecureCookies, + }, + }, + callbackUrl: { + name: `${cookiePrefix}next-auth.callback-url`, + options: { + httpOnly: true, + sameSite: "lax", + path: "/", + secure: useSecureCookies, + }, + }, + csrfToken: { + // Default to __Host- for CSRF token for additional protection if using useSecureCookies + // NB: The `__Host-` prefix is stricter than the `__Secure-` prefix. + name: `${useSecureCookies ? "__Host-" : ""}next-auth.csrf-token`, + options: { + httpOnly: true, + sameSite: "lax", + path: "/", + secure: useSecureCookies, + }, + }, + pkceCodeVerifier: { + name: `${cookiePrefix}next-auth.pkce.code_verifier`, + options: { + httpOnly: true, + sameSite: "lax", + path: "/", + secure: useSecureCookies, + maxAge: 60 * 15, // 15 minutes in seconds + }, + }, + state: { + name: `${cookiePrefix}next-auth.state`, + options: { + httpOnly: true, + sameSite: "lax", + path: "/", + secure: useSecureCookies, + maxAge: 60 * 15, // 15 minutes in seconds + }, + }, + nonce: { + name: `${cookiePrefix}next-auth.nonce`, + options: { + httpOnly: true, + sameSite: "lax", + path: "/", + secure: useSecureCookies, + }, + }, + } +} + +export interface Cookie extends CookieOption { + value: string +} + +type Chunks = Record + +export class SessionStore { + #chunks: Chunks = {} + #option: CookieOption + #logger: LoggerInstance | Console + + constructor( + option: CookieOption, + req: Partial<{ cookies: any; headers: any }>, + logger: LoggerInstance | Console + ) { + this.#logger = logger + this.#option = option + + const { cookies } = req + const { name: cookieName } = option + + if (typeof cookies?.getAll === "function") { + // Next.js ^v13.0.1 (Edge Env) + for (const { name, value } of cookies.getAll()) { + if (name.startsWith(cookieName)) { + this.#chunks[name] = value + } + } + } else if (cookies instanceof Map) { + for (const name of cookies.keys()) { + if (name.startsWith(cookieName)) this.#chunks[name] = cookies.get(name) + } + } else { + for (const name in cookies) { + if (name.startsWith(cookieName)) this.#chunks[name] = cookies[name] + } + } + } + + get value() { + return Object.values(this.#chunks)?.join("") + } + + /** Given a cookie, return a list of cookies, chunked to fit the allowed cookie size. */ + #chunk(cookie: Cookie): Cookie[] { + const chunkCount = Math.ceil(cookie.value.length / CHUNK_SIZE) + + if (chunkCount === 1) { + this.#chunks[cookie.name] = cookie.value + return [cookie] + } + + const cookies: Cookie[] = [] + for (let i = 0; i < chunkCount; i++) { + const name = `${cookie.name}.${i}` + const value = cookie.value.substr(i * CHUNK_SIZE, CHUNK_SIZE) + cookies.push({ ...cookie, name, value }) + this.#chunks[name] = value + } + + this.#logger.debug("CHUNKING_SESSION_COOKIE", { + message: `Session cookie exceeds allowed ${ALLOWED_COOKIE_SIZE} bytes.`, + emptyCookieSize: ESTIMATED_EMPTY_COOKIE_SIZE, + valueSize: cookie.value.length, + chunks: cookies.map((c) => c.value.length + ESTIMATED_EMPTY_COOKIE_SIZE), + }) + + return cookies + } + + /** Returns cleaned cookie chunks. */ + #clean(): Record { + const cleanedChunks: Record = {} + for (const name in this.#chunks) { + delete this.#chunks?.[name] + cleanedChunks[name] = { + name, + value: "", + options: { ...this.#option.options, maxAge: 0 }, + } + } + return cleanedChunks + } + + /** + * Given a cookie value, return new cookies, chunked, to fit the allowed cookie size. + * If the cookie has changed from chunked to unchunked or vice versa, + * it deletes the old cookies as well. + */ + chunk(value: string, options: Partial): Cookie[] { + // Assume all cookies should be cleaned by default + const cookies: Record = this.#clean() + + // Calculate new chunks + const chunked = this.#chunk({ + name: this.#option.name, + value, + options: { ...this.#option.options, ...options }, + }) + + // Update stored chunks / cookies + for (const chunk of chunked) { + cookies[chunk.name] = chunk + } + + return Object.values(cookies) + } + + /** Returns a list of cookies that should be cleaned. */ + clean(): Cookie[] { + return Object.values(this.#clean()) + } +} diff --git a/packages/core/src/lib/csrf-token.ts b/packages/core/src/lib/csrf-token.ts new file mode 100644 index 0000000000..5864bd6e67 --- /dev/null +++ b/packages/core/src/lib/csrf-token.ts @@ -0,0 +1,54 @@ +import { createHash, randomString } from "./web" +import type { InternalOptions } from "./types" + +interface CreateCSRFTokenParams { + options: InternalOptions + cookieValue?: string + isPost: boolean + bodyValue?: string +} + +/** + * Ensure CSRF Token cookie is set for any subsequent requests. + * Used as part of the strategy for mitigation for CSRF tokens. + * + * Creates a cookie like 'next-auth.csrf-token' with the value 'token|hash', + * where 'token' is the CSRF token and 'hash' is a hash made of the token and + * the secret, and the two values are joined by a pipe '|'. By storing the + * value and the hash of the value (with the secret used as a salt) we can + * verify the cookie was set by the server and not by a malicous attacker. + * + * For more details, see the following OWASP links: + * https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html#double-submit-cookie + * https://owasp.org/www-chapter-london/assets/slides/David_Johansson-Double_Defeat_of_Double-Submit_Cookie.pdf + */ +export async function createCSRFToken({ + options, + cookieValue, + isPost, + bodyValue, +}: CreateCSRFTokenParams) { + if (cookieValue) { + const [csrfToken, csrfTokenHash] = cookieValue.split("|") + + const expectedCsrfTokenHash = await createHash( + `${csrfToken}${options.secret}` + ) + + if (csrfTokenHash === expectedCsrfTokenHash) { + // If hash matches then we trust the CSRF token value + // If this is a POST request and the CSRF Token in the POST request matches + // the cookie we have already verified is the one we have set, then the token is verified! + const csrfTokenVerified = isPost && csrfToken === bodyValue + + return { csrfTokenVerified, csrfToken } + } + } + + // New CSRF token + const csrfToken = randomString(32) + const csrfTokenHash = await createHash(`${csrfToken}${options.secret}`) + const cookie = `${csrfToken}|${csrfTokenHash}` + + return { cookie, csrfToken } +} diff --git a/packages/core/src/lib/default-callbacks.ts b/packages/core/src/lib/default-callbacks.ts new file mode 100644 index 0000000000..ed7e3d5152 --- /dev/null +++ b/packages/core/src/lib/default-callbacks.ts @@ -0,0 +1,18 @@ +import type { CallbacksOptions } from ".." + +export const defaultCallbacks: CallbacksOptions = { + signIn() { + return true + }, + redirect({ url, baseUrl }) { + if (url.startsWith("/")) return `${baseUrl}${url}` + else if (new URL(url).origin === baseUrl) return url + return baseUrl + }, + session({ session }) { + return session + }, + jwt({ token }) { + return token + }, +} diff --git a/packages/core/src/lib/email/getUserFromEmail.ts b/packages/core/src/lib/email/getUserFromEmail.ts new file mode 100644 index 0000000000..c0b0b56db2 --- /dev/null +++ b/packages/core/src/lib/email/getUserFromEmail.ts @@ -0,0 +1,20 @@ +import type { AdapterUser } from "../../adapters" +import type { InternalOptions } from "../.." + +/** + * Query the database for a user by email address. + * If is an existing user return a user object (otherwise use placeholder). + */ +export default async function getAdapterUserFromEmail({ + email, + adapter, +}: { + email: string + adapter: InternalOptions<"email">["adapter"] +}): Promise { + const { getUserByEmail } = adapter + const adapterUser = email ? await getUserByEmail(email) : null + if (adapterUser) return adapterUser + + return { id: email, email, emailVerified: null } +} diff --git a/packages/core/src/lib/email/signin.ts b/packages/core/src/lib/email/signin.ts new file mode 100644 index 0000000000..5ba00043b7 --- /dev/null +++ b/packages/core/src/lib/email/signin.ts @@ -0,0 +1,49 @@ +import { randomString, createHash } from "../web" +import type { InternalOptions } from "../.." + +/** + * Starts an e-mail login flow, by generating a token, + * and sending it to the user's e-mail (with the help of a DB adapter) + */ +export default async function email( + identifier: string, + options: InternalOptions<"email"> +): Promise { + const { url, adapter, provider, callbackUrl, theme } = options + // Generate token + const token = + (await provider.generateVerificationToken?.()) ?? randomString(32) + + const ONE_DAY_IN_SECONDS = 86400 + const expires = new Date( + Date.now() + (provider.maxAge ?? ONE_DAY_IN_SECONDS) * 1000 + ) + + // Generate a link with email, unhashed token and callback url + const params = new URLSearchParams({ callbackUrl, token, email: identifier }) + const _url = `${url}/callback/${provider.id}?${params}` + + const secret = provider.secret ?? options.secret + await Promise.all([ + // Send to user + provider.sendVerificationRequest({ + identifier, + token, + expires, + url: _url, + provider, + theme, + }), + // Save in database + adapter.createVerificationToken({ + identifier, + token: await createHash(`${token}${secret}`), + expires, + }), + ]) + + return `${url}/verify-request?${new URLSearchParams({ + provider: provider.id, + type: provider.type, + })}` +} diff --git a/packages/core/src/lib/errors.ts b/packages/core/src/lib/errors.ts new file mode 100644 index 0000000000..1f569103ed --- /dev/null +++ b/packages/core/src/lib/errors.ts @@ -0,0 +1,141 @@ +import type { EventCallbacks, LoggerInstance } from "./types" + +/** + * Same as the default `Error`, but it is JSON serializable. + * @source https://iaincollins.medium.com/error-handling-in-javascript-a6172ccdf9af + */ +export class UnknownError extends Error { + code: string + constructor(error: Error | string) { + // Support passing error or string + super((error as Error)?.message ?? error) + this.name = "UnknownError" + this.code = (error as any).code + if (error instanceof Error) { + this.stack = error.stack + } + } + + toJSON() { + return { + name: this.name, + message: this.message, + stack: this.stack, + } + } +} + +export class OAuthCallbackError extends UnknownError { + name = "OAuthCallbackError" +} + +/** + * Thrown when an Email address is already associated with an account + * but the user is trying an OAuth account that is not linked to it. + */ +export class AccountNotLinkedError extends UnknownError { + name = "AccountNotLinkedError" +} + +export class MissingAPIRoute extends UnknownError { + name = "MissingAPIRouteError" + code = "MISSING_NEXTAUTH_API_ROUTE_ERROR" +} + +export class MissingSecret extends UnknownError { + name = "MissingSecretError" + code = "NO_SECRET" +} + +export class MissingAuthorize extends UnknownError { + name = "MissingAuthorizeError" + code = "CALLBACK_CREDENTIALS_HANDLER_ERROR" +} + +export class MissingAdapter extends UnknownError { + name = "MissingAdapterError" + code = "EMAIL_REQUIRES_ADAPTER_ERROR" +} + +export class MissingAdapterMethods extends UnknownError { + name = "MissingAdapterMethodsError" + code = "MISSING_ADAPTER_METHODS_ERROR" +} + +export class UnsupportedStrategy extends UnknownError { + name = "UnsupportedStrategyError" + code = "CALLBACK_CREDENTIALS_JWT_ERROR" +} + +export class InvalidCallbackUrl extends UnknownError { + name = "InvalidCallbackUrlError" + code = "INVALID_CALLBACK_URL_ERROR" +} + +export class InvalidEndpoints extends UnknownError { + name = "InvalidEndpoints" + code = "INVALID_ENDPOINTS_ERROR" +} +export class UnknownAction extends UnknownError { + name = "UnknownAction" + code = "UNKNOWN_ACTION_ERROR" +} + +export class UntrustedHost extends UnknownError { + name = "UntrustedHost" + code = "UNTRUST_HOST_ERROR" +} + +type Method = (...args: any[]) => Promise + +export function upperSnake(s: string) { + return s.replace(/([A-Z])/g, "_$1").toUpperCase() +} + +export function capitalize(s: string) { + return `${s[0].toUpperCase()}${s.slice(1)}` +} + +/** + * Wraps an object of methods and adds error handling. + */ +export function eventsErrorHandler( + methods: Partial, + logger: LoggerInstance +): Partial { + return Object.keys(methods).reduce((acc, name) => { + acc[name] = async (...args: any[]) => { + try { + const method: Method = methods[name as keyof Method] + return await method(...args) + } catch (e) { + logger.error(`${upperSnake(name)}_EVENT_ERROR`, e as Error) + } + } + return acc + }, {}) +} + +/** Handles adapter induced errors. */ +export function adapterErrorHandler( + adapter: TAdapter | undefined, + logger: LoggerInstance +): TAdapter | undefined { + if (!adapter) return + + return Object.keys(adapter).reduce((acc, name) => { + acc[name] = async (...args: any[]) => { + try { + logger.debug(`adapter_${name}`, { args }) + const method: Method = adapter[name as keyof Method] + return await method(...args) + } catch (error) { + logger.error(`adapter_error_${name}`, error as Error) + const e = new UnknownError(error as Error) + e.name = `${capitalize(name)}Error` + throw e + } + } + return acc + }, {}) +} diff --git a/packages/core/src/lib/init.ts b/packages/core/src/lib/init.ts new file mode 100644 index 0000000000..2d21101363 --- /dev/null +++ b/packages/core/src/lib/init.ts @@ -0,0 +1,152 @@ +import { adapterErrorHandler, eventsErrorHandler } from "./errors" +import * as jwt from "../jwt" +import { createCallbackUrl } from "./callback-url" +import * as cookie from "./cookie" +import { createCSRFToken } from "./csrf-token" +import { defaultCallbacks } from "./default-callbacks" +import parseProviders from "./providers" +import logger from "./utils/logger" +import parseUrl from "./utils/parse-url" + +import type { AuthOptions, InternalOptions, RequestInternal } from ".." + +interface InitParams { + url: URL + authOptions: AuthOptions + 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? */ + isPost: boolean + cookies: RequestInternal["cookies"] +} + +/** Initialize all internal options and cookies. */ +export async function init({ + authOptions, + providerId, + action, + url: reqUrl, + cookies: reqCookies, + callbackUrl: reqCallbackUrl, + csrfToken: reqCsrfToken, + isPost, +}: InitParams): Promise<{ + options: InternalOptions + cookies: cookie.Cookie[] +}> { + // TODO: move this to web.ts + const parsed = parseUrl( + reqUrl.origin + + reqUrl.pathname.replace(`/${action}`, "").replace(`/${providerId}`, "") + ) + const url = new URL(parsed.toString()) + + const { providers, provider } = parseProviders({ + providers: authOptions.providers, + url, + providerId, + }) + + const maxAge = 30 * 24 * 60 * 60 // Sessions expire after 30 days of being idle by default + + // User provided options are overriden by other options, + // except for the options with special handling above + const options: InternalOptions = { + debug: false, + pages: {}, + theme: { + colorScheme: "auto", + logo: "", + brandColor: "", + buttonText: "", + }, + // Custom options override defaults + ...authOptions, + // These computed settings can have values in userOptions but we override them + // and are request-specific. + url, + action, + // @ts-expect-errors + provider, + cookies: { + ...cookie.defaultCookies( + authOptions.useSecureCookies ?? url.protocol === "https:" + ), + // Allow user cookie options to override any cookie settings above + ...authOptions.cookies, + }, + providers, + // Session options + session: { + // If no adapter specified, force use of JSON Web Tokens (stateless) + strategy: authOptions.adapter ? "database" : "jwt", + maxAge, + updateAge: 24 * 60 * 60, + generateSessionToken: crypto.randomUUID, + ...authOptions.session, + }, + // JWT options + jwt: { + // Asserted in assert.ts + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + secret: authOptions.secret!, + maxAge, // same as session maxAge, + encode: jwt.encode, + decode: jwt.decode, + ...authOptions.jwt, + }, + // Event messages + events: eventsErrorHandler(authOptions.events ?? {}, logger), + adapter: adapterErrorHandler(authOptions.adapter, logger), + // Callback functions + callbacks: { ...defaultCallbacks, ...authOptions.callbacks }, + logger, + callbackUrl: url.origin, + } + + // Init cookies + + const cookies: cookie.Cookie[] = [] + + const { + csrfToken, + cookie: csrfCookie, + csrfTokenVerified, + } = await createCSRFToken({ + options, + cookieValue: reqCookies?.[options.cookies.csrfToken.name], + isPost, + bodyValue: reqCsrfToken, + }) + + options.csrfToken = csrfToken + options.csrfTokenVerified = csrfTokenVerified + + if (csrfCookie) { + cookies.push({ + name: options.cookies.csrfToken.name, + value: csrfCookie, + options: options.cookies.csrfToken.options, + }) + } + + const { callbackUrl, callbackUrlCookie } = await createCallbackUrl({ + options, + cookieValue: reqCookies?.[options.cookies.callbackUrl.name], + paramValue: reqCallbackUrl, + }) + options.callbackUrl = callbackUrl + if (callbackUrlCookie) { + cookies.push({ + name: options.cookies.callbackUrl.name, + value: callbackUrlCookie, + options: options.cookies.callbackUrl.options, + }) + } + + return { options, cookies } +} diff --git a/packages/core/src/lib/oauth/authorization-url.ts b/packages/core/src/lib/oauth/authorization-url.ts new file mode 100644 index 0000000000..275d04c733 --- /dev/null +++ b/packages/core/src/lib/oauth/authorization-url.ts @@ -0,0 +1,147 @@ +import * as o from "oauth4webapi" + +import type { + CookiesOptions, + InternalOptions, + RequestInternal, + ResponseInternal, +} from "../.." +import type { Cookie } from "../cookie" + +/** + * Generates an authorization/request token URL. + * + * [OAuth 2](https://www.oauth.com/oauth2-servers/authorization/the-authorization-request/) + */ +export async function getAuthorizationUrl({ + options, + query, +}: { + options: InternalOptions<"oauth"> + query: RequestInternal["query"] +}): Promise { + const { logger, provider } = options + + let url = provider.authorization?.url + let as: o.AuthorizationServer | undefined + + if (!url) { + // If url is undefined, we assume that issuer is always defined + // We check this in assert.ts + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const issuer = new URL(provider.issuer!) + const discoveryResponse = await o.discoveryRequest(issuer) + const as = await o.processDiscoveryResponse(issuer, discoveryResponse) + + if (!as.authorization_endpoint) { + throw new TypeError( + "Authorization server did not provide an authorization endpoint." + ) + } + + url = new URL(as.authorization_endpoint) + } + + const authParams = url.searchParams + const params = Object.assign( + { + response_type: "code", + // clientId can technically be undefined, should we check this in assert.ts or rely on the Authorization Server to do it? + client_id: provider.clientId, + redirect_uri: provider.callbackUrl, + // @ts-expect-error TODO: + ...provider.authorization?.params, + }, // Defaults + Object.fromEntries(authParams), // From provider config + query // From `signIn` call + ) + + for (const k in params) authParams.set(k, params[k]) + + const cookies: Cookie[] = [] + + if (provider.checks?.includes("state")) { + const { value, raw } = await createState(options) + authParams.set("state", raw) + cookies.push(value) + } + + if (provider.checks?.includes("pkce")) { + if (as && !as.code_challenge_methods_supported?.includes("S256")) { + // We assume S256 PKCE support, if the server does not advertise that, + // a random `nonce` must be used for CSRF protection. + provider.checks = ["nonce"] + } else { + const { code_challenge, pkce } = await createPKCE(options) + authParams.set("code_challenge", code_challenge) + authParams.set("code_challenge_method", "S256") + cookies.push(pkce) + } + } + + if (provider.checks?.includes("nonce")) { + const nonce = await createNonce(options) + authParams.set("nonce", nonce.value) + cookies.push(nonce) + } + + url.searchParams.delete("nextauth") + + // TODO: This does not work in normalizeOAuth because authorization endpoint can come from discovery + // Need to make normalizeOAuth async + if (provider.type === "oidc" && !url.searchParams.has("scope")) { + url.searchParams.set("scope", "openid profile email") + } + + logger.debug("GET_AUTHORIZATION_URL", { url, cookies, provider }) + return { redirect: url, cookies } +} + +/** Returns a signed cookie. */ +export async function signCookie( + type: keyof CookiesOptions, + value: string, + maxAge: number, + options: InternalOptions<"oauth"> +): Promise { + const { cookies, jwt, 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({ ...jwt, maxAge, token: { value } }), + options: { ...cookies[type].options, expires }, + } +} + +const STATE_MAX_AGE = 60 * 15 // 15 minutes in seconds +async function createState(options: InternalOptions<"oauth">) { + const raw = o.generateRandomState() + const maxAge = STATE_MAX_AGE + const value = await signCookie("state", raw, maxAge, options) + return { value, raw } +} + +const PKCE_MAX_AGE = 60 * 15 // 15 minutes in seconds +async function createPKCE(options: InternalOptions<"oauth">) { + const code_verifier = o.generateRandomCodeVerifier() + const code_challenge = await o.calculatePKCECodeChallenge(code_verifier) + const maxAge = PKCE_MAX_AGE + const pkce = await signCookie( + "pkceCodeVerifier", + code_verifier, + maxAge, + options + ) + return { code_challenge, pkce } +} + +const NONCE_MAX_AGE = 60 * 15 // 15 minutes in seconds +async function createNonce(options: InternalOptions<"oauth">) { + const raw = o.generateRandomNonce() + const maxAge = NONCE_MAX_AGE + return await signCookie("nonce", raw, maxAge, options) +} diff --git a/packages/core/src/lib/oauth/callback.ts b/packages/core/src/lib/oauth/callback.ts new file mode 100644 index 0000000000..e31df48879 --- /dev/null +++ b/packages/core/src/lib/oauth/callback.ts @@ -0,0 +1,221 @@ +import { OAuthCallbackError } from "../errors" +import { useNonce } from "./nonce-handler" +import { usePKCECodeVerifier } from "./pkce-handler" +import { useState } from "./state-handler" +import * as o from "oauth4webapi" + +import type { + InternalOptions, + LoggerInstance, + Profile, + RequestInternal, + TokenSet, +} from "../.." +import type { OAuthConfigInternal } from "../../providers" +import type { Cookie } from "../cookie" + +export async function handleOAuthCallback(params: { + options: InternalOptions<"oauth"> + query: RequestInternal["query"] + body: RequestInternal["body"] + cookies: RequestInternal["cookies"] +}) { + const { options, query, body, cookies } = params + const { logger, provider } = options + + const errorMessage = body?.error ?? query?.error + if (errorMessage) { + const error = new Error(errorMessage) + logger.error("OAUTH_CALLBACK_HANDLER_ERROR", { + error, + error_description: query?.error_description, + providerId: provider.id, + }) + logger.debug("OAUTH_CALLBACK_HANDLER_ERROR", { body }) + throw error + } + + try { + let as: o.AuthorizationServer + + if (!provider.token?.url && !provider.userinfo?.url) { + // We assume that issuer is always defined as this has been asserted earlier + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const issuer = new URL(provider.issuer!) + const discoveryResponse = await o.discoveryRequest(issuer) + const discoveredAs = await o.processDiscoveryResponse( + issuer, + discoveryResponse + ) + + if (!discoveredAs.token_endpoint) + throw new TypeError( + "TODO: Authorization server did not provide a token endpoint." + ) + + if (!discoveredAs.userinfo_endpoint) + throw new TypeError( + "TODO: Authorization server did not provide a userinfo endpoint." + ) + + as = discoveredAs + } else { + as = { + issuer: provider.issuer ?? "https://a", // TODO: review fallback issuer + token_endpoint: provider.token?.url.toString(), + userinfo_endpoint: provider.userinfo?.url.toString(), + } + } + + const client: o.Client = { + client_id: provider.clientId, + client_secret: provider.clientSecret, + ...provider.client, + } + + const resCookies: Cookie[] = [] + + const state = await useState(cookies?.[options.cookies.state.name], options) + if (state) resCookies.push(state.cookie) + + const codeVerifier = await usePKCECodeVerifier( + cookies?.[options.cookies.pkceCodeVerifier.name], + options + ) + if (codeVerifier) resCookies.push(codeVerifier.cookie) + + // TODO: + const nonce = await useNonce(cookies?.[options.cookies.nonce.name], options) + if (nonce && provider.type === "oidc") { + resCookies.push(nonce.cookie) + } + + const parameters = o.validateAuthResponse( + as, + client, + new URLSearchParams(query), + provider.checks.includes("state") ? state?.value : o.skipStateCheck + ) + + if (o.isOAuth2Error(parameters)) { + console.log("error", parameters) + throw new Error("TODO: Handle OAuth 2.0 redirect error") + } + + const codeGrantResponse = await o.authorizationCodeGrantRequest( + as, + client, + parameters, + provider.callbackUrl, + codeVerifier?.codeVerifier ?? "auth" // TODO: review fallback code verifier + ) + + let challenges: o.WWWAuthenticateChallenge[] | undefined + if ((challenges = o.parseWwwAuthenticateChallenges(codeGrantResponse))) { + for (const challenge of challenges) { + console.log("challenge", challenge) + } + throw new Error("TODO: Handle www-authenticate challenges as needed") + } + + let profile: Profile = {} + let tokens: TokenSet + + if (provider.type === "oidc") { + const result = await o.processAuthorizationCodeOpenIDResponse( + as, + client, + codeGrantResponse + ) + + if (o.isOAuth2Error(result)) { + console.log("error", result) + throw new Error("TODO: Handle OIDC response body error") + } + + profile = o.getValidatedIdTokenClaims(result) + tokens = result + } else { + tokens = await o.processAuthorizationCodeOAuth2Response( + as, + client, + codeGrantResponse + ) + if (o.isOAuth2Error(tokens as any)) { + console.log("error", tokens) + throw new Error("TODO: Handle OAuth 2.0 response body error") + } + + if (provider.userinfo?.request) { + profile = await provider.userinfo.request({ tokens, provider }) + } else if (provider.userinfo?.url) { + const userinfoResponse = await o.userInfoRequest( + as, + client, + (tokens as any).access_token + ) + profile = await userinfoResponse.json() + } + } + + const profileResult = await getProfile({ + profile, + provider, + tokens, + logger, + }) + + return { ...profileResult, cookies: resCookies } + } catch (error) { + throw new OAuthCallbackError(error as Error) + } +} + +interface GetProfileParams { + profile: Profile + tokens: TokenSet + provider: OAuthConfigInternal + logger: LoggerInstance +} + +/** Returns profile, raw profile and auth provider details */ +async function getProfile({ + profile: OAuthProfile, + tokens, + provider, + logger, +}: GetProfileParams) { + try { + logger.debug("PROFILE_DATA", { OAuthProfile }) + const profile = await provider.profile(OAuthProfile, tokens) + profile.email = profile.email?.toLowerCase() + if (!profile.id) + throw new TypeError( + `Profile id is missing in ${provider.name} OAuth profile response` + ) + + // Return profile, raw profile and auth provider details + return { + profile, + account: { + provider: provider.id, + type: provider.type, + providerAccountId: profile.id.toString(), + ...tokens, + }, + OAuthProfile, + } + } catch (error) { + // If we didn't get a response either there was a problem with the provider + // response *or* the user cancelled the action with the provider. + // + // Unfortuately, we can't tell which - at least not in a way that works for + // all providers, so we return an empty object; the user should then be + // redirected back to the sign up page. We log the error to help developers + // who might be trying to debug this when configuring a new provider. + logger.error("OAUTH_PARSE_PROFILE_ERROR", { + error: error as Error, + OAuthProfile, + }) + } +} diff --git a/packages/core/src/lib/oauth/nonce-handler.ts b/packages/core/src/lib/oauth/nonce-handler.ts new file mode 100644 index 0000000000..1096ae2b1f --- /dev/null +++ b/packages/core/src/lib/oauth/nonce-handler.ts @@ -0,0 +1,76 @@ +import * as o from "oauth4webapi" +import * as jwt from "../../jwt" + +import type { InternalOptions } from "../.." +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 = o.generateRandomNonce() + + 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?.value ?? undefined, + cookie: { + name: cookies.nonce.name, + value: "", + options: { ...cookies.nonce.options, maxAge: 0 }, + }, + } +} diff --git a/packages/core/src/lib/oauth/pkce-handler.ts b/packages/core/src/lib/oauth/pkce-handler.ts new file mode 100644 index 0000000000..ab003b8424 --- /dev/null +++ b/packages/core/src/lib/oauth/pkce-handler.ts @@ -0,0 +1,87 @@ +import * as o from "oauth4webapi" +import * as jwt from "../../jwt" + +import type { InternalOptions } from "../.." +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 = o.generateRandomCodeVerifier() + const code_challenge = await o.calculatePKCECodeChallenge(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?.value ?? undefined, + cookie: { + name: cookies.pkceCodeVerifier.name, + value: "", + options: { ...cookies.pkceCodeVerifier.options, maxAge: 0 }, + }, + } +} diff --git a/packages/core/src/lib/oauth/state-handler.ts b/packages/core/src/lib/oauth/state-handler.ts new file mode 100644 index 0000000000..548970cd73 --- /dev/null +++ b/packages/core/src/lib/oauth/state-handler.ts @@ -0,0 +1,63 @@ +import type { InternalOptions } from "../.." +import type { Cookie } from "../cookie" +import * as o from "oauth4webapi" + +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 = o.generateRandomState() + 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") || !state) return + + const value = (await jwt.decode({ ...options.jwt, token: state })) as any + + return { + value: value?.value ?? undefined, + cookie: { + name: cookies.state.name, + value: "", + options: { ...cookies.pkceCodeVerifier.options, maxAge: 0 }, + }, + } +} diff --git a/packages/core/src/lib/pages/error.tsx b/packages/core/src/lib/pages/error.tsx new file mode 100644 index 0000000000..a2ea8e5f64 --- /dev/null +++ b/packages/core/src/lib/pages/error.tsx @@ -0,0 +1,113 @@ +import type { Theme } from "../.." + +/** + * The following errors are passed as error query parameters to the default or overridden error page. + * + * [Documentation](https://next-auth.js.org/configuration/pages#error-page) */ +export type ErrorType = + | "default" + | "configuration" + | "accessdenied" + | "verification" + +export interface ErrorProps { + url?: URL + theme?: Theme + error?: ErrorType +} + +interface ErrorView { + status: number + heading: string + message: JSX.Element + signin?: JSX.Element +} + +/** Renders an error page. */ +export default function ErrorPage(props: ErrorProps) { + const { url, error = "default", theme } = props + const signinPageUrl = `${url}/signin` + + const errors: Record = { + default: { + status: 200, + heading: "Error", + message: ( +

+ + {url?.host} + +

+ ), + }, + configuration: { + status: 500, + heading: "Server error", + message: ( +
+

There is a problem with the server configuration.

+

Check the server logs for more information.

+
+ ), + }, + accessdenied: { + status: 403, + heading: "Access Denied", + message: ( +
+

You do not have permission to sign in.

+

+ + Sign in + +

+
+ ), + }, + verification: { + status: 403, + heading: "Unable to sign in", + message: ( +
+

The sign in link is no longer valid.

+

It may have been used already or it may have expired.

+
+ ), + signin: ( +

+ + Sign in + +

+ ), + }, + } + + const { status, heading, message, signin } = + errors[error.toLowerCase()] ?? errors.default + + return { + status, + html: ( +
+ {theme?.brandColor && ( + ${title}
${renderToString(html)}
`, + } + } + + return { + signin(props?: any) { + return send({ + html: SigninPage({ + csrfToken: params.csrfToken, + // We only want to render providers + providers: params.providers?.filter( + (provider) => + // Always render oauth and email type providers + ["email", "oauth", "oidc"].includes(provider.type) || + // Only render credentials type provider if credentials are defined + (provider.type === "credentials" && provider.credentials) || + // Don't render other provider types + false + ), + callbackUrl: params.callbackUrl, + theme, + ...query, + ...props, + }), + title: "Sign In", + }) + }, + signout(props?: any) { + return send({ + html: SignoutPage({ + csrfToken: params.csrfToken, + url, + theme, + ...props, + }), + title: "Sign Out", + }) + }, + verifyRequest(props?: any) { + return send({ + html: VerifyRequestPage({ url, theme, ...props }), + title: "Verify Request", + }) + }, + error(props?: { error?: ErrorType }) { + return send({ + ...ErrorPage({ url, theme, ...props }), + title: "Error", + }) + }, + } +} diff --git a/packages/core/src/lib/pages/signin.tsx b/packages/core/src/lib/pages/signin.tsx new file mode 100644 index 0000000000..0543ea5160 --- /dev/null +++ b/packages/core/src/lib/pages/signin.tsx @@ -0,0 +1,184 @@ +import type { InternalProvider, Theme } from "../.." +import type { CSSProperties } from "react" + +/** + * The following errors are passed as error query parameters to the default or overridden sign-in page. + * + * [Documentation](https://next-auth.js.org/configuration/pages#sign-in-page) */ +export type SignInErrorTypes = + | "Signin" + | "OAuthSignin" + | "OAuthCallback" + | "OAuthCreateAccount" + | "EmailCreateAccount" + | "Callback" + | "OAuthAccountNotLinked" + | "EmailSignin" + | "CredentialsSignin" + | "SessionRequired" + | "default" + +export interface SignInServerPageParams { + csrfToken: string + providers: InternalProvider[] + callbackUrl: string + email: string + error: SignInErrorTypes + theme: Theme +} + +export default function SigninPage(props: SignInServerPageParams) { + const { + csrfToken, + providers = [], + callbackUrl, + theme, + email, + error: errorType, + } = props + + if (typeof document !== "undefined" && theme.brandColor) { + document.documentElement.style.setProperty( + "--brand-color", + theme.brandColor + ) + } + + const errors: Record = { + Signin: "Try signing in with a different account.", + OAuthSignin: "Try signing in with a different account.", + OAuthCallback: "Try signing in with a different account.", + OAuthCreateAccount: "Try signing in with a different account.", + EmailCreateAccount: "Try signing in with a different account.", + Callback: "Try signing in with a different account.", + OAuthAccountNotLinked: + "To confirm your identity, sign in with the same account you used originally.", + EmailSignin: "The e-mail could not be sent.", + CredentialsSignin: + "Sign in failed. Check the details you provided are correct.", + SessionRequired: "Please sign in to access this page.", + default: "Unable to sign in.", + } + + const error = errorType && (errors[errorType] ?? errors.default) + + // TODO: move logos + const logos = + "https://raw.githubusercontent.com/nextauthjs/next-auth/main/packages/next-auth/provider-logos" + return ( +
+ {theme.brandColor && ( +