diff --git a/.gitignore b/.gitignore index b4ea36f5..3cebcff1 100644 --- a/.gitignore +++ b/.gitignore @@ -26,7 +26,15 @@ dist # Generated files .docusaurus .cache-loader - +packages/next-auth/providers +packages/next-auth/src/providers/oauth-types.ts +packages/next-auth/client +packages/next-auth/css +packages/next-auth/utils +packages/next-auth/core +packages/next-auth/jwt +packages/next-auth/react +packages/next-auth/next packages/*/*.js packages/*/*.d.ts packages/*/*.d.ts.map diff --git a/.prettierignore b/.prettierignore index cb1da958..a1602c86 100644 --- a/.prettierignore +++ b/.prettierignore @@ -40,6 +40,9 @@ packages/core/src/lib/pages/styles.ts packages/frameworks-sveltekit/package packages/frameworks-sveltekit/vite.config.{js,ts}.timestamp-* +# next-auth +packages/next-auth/src/providers/oauth-types.ts +packages/next-auth/css/index.css # Adapters .branches diff --git a/.vscode/settings.json b/.vscode/settings.json index fbd922c3..858c47eb 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,7 +1,7 @@ { "files.exclude": { - "packages/core/{lib,providers,*.js,*.d.ts*}": true, - "packages/next-auth/{lib,*.js,*.d.ts*}": true, + "packages/core/{lib,providers,*.js,*.d.ts,*.d.ts.map}": true, + "packages/next-auth/{client,core,css,jwt,next,providers,react,utils,*.js,*.d.ts}": true }, "typescript.tsdk": "node_modules/typescript/lib", "openInGitHub.remote.branch": "main" diff --git a/packages/next-auth/.npmrc b/packages/next-auth/.npmrc new file mode 100644 index 00000000..ae643592 --- /dev/null +++ b/packages/next-auth/.npmrc @@ -0,0 +1 @@ +//registry.npmjs.org/:_authToken=${NPM_TOKEN} diff --git a/packages/next-auth/README.md b/packages/next-auth/README.md new file mode 100644 index 00000000..c001448c --- /dev/null +++ b/packages/next-auth/README.md @@ -0,0 +1,254 @@ +

+
+ +

NextAuth.js

+

Authentication for Next.js

+

+ Open Source. Full Stack. Own Your Data. +

+

+ + Release + + + Bundle Size + + + Downloads + + + Github Stars + + + Github Stable Release + +

+

+ +## Overview + +NextAuth.js is a complete open source authentication solution for [Next.js](http://nextjs.org/) applications. + +It is designed from the ground up to support Next.js and Serverless. + +This is a monorepo containing the following packages / projects: + +1. The primary `next-auth` package +2. A development test application +3. All `@next-auth/*-adapter` packages +4. The documentation site + +## Getting Started + +``` +npm install next-auth +``` + +The easiest way to continue getting started, is to follow the [getting started](https://next-auth.js.org/getting-started/example) section in our docs. + +We also have a section of [tutorials](https://next-auth.js.org/tutorials) for those looking for more specific examples. + +See [next-auth.js.org](https://next-auth.js.org) for more information and documentation. + +## Features + +### Flexible and easy to use + +- Designed to work with any OAuth service, it supports OAuth 1.0, 1.0A and 2.0 +- Built-in support for [many popular sign-in services](https://next-auth.js.org/providers) +- Supports email / passwordless authentication +- Supports stateless authentication with any backend (Active Directory, LDAP, etc) +- Supports both JSON Web Tokens and database sessions +- Designed for Serverless but runs anywhere (AWS Lambda, Docker, Heroku, etc…) + +### Own your own data + +NextAuth.js can be used with or without a database. + +- An open source solution that allows you to keep control of your data +- Supports Bring Your Own Database (BYOD) and can be used with any database +- Built-in support for [MySQL, MariaDB, Postgres, Microsoft SQL Server, MongoDB and SQLite](https://next-auth.js.org/configuration/databases) +- Works great with databases from popular hosting providers +- Can also be used _without a database_ (e.g. OAuth + JWT) + +### Secure by default + +- Promotes the use of passwordless sign-in mechanisms +- Designed to be secure by default and encourage best practices for safeguarding user data +- Uses Cross-Site Request Forgery (CSRF) Tokens on POST routes (sign in, sign out) +- Default cookie policy aims for the most restrictive policy appropriate for each cookie +- When JSON Web Tokens are enabled, they are encrypted by default (JWE) with A256GCM +- Auto-generates symmetric signing and encryption keys for developer convenience +- Features tab/window syncing and session polling to support short lived sessions +- Attempts to implement the latest guidance published by [Open Web Application Security Project](https://owasp.org) + +Advanced options allow you to define your own routines to handle controlling what accounts are allowed to sign in, for encoding and decoding JSON Web Tokens and to set custom cookie security policies and session properties, so you can control who is able to sign in and how often sessions have to be re-validated. + +### TypeScript + +NextAuth.js comes with built-in types. For more information and usage, check out +the [TypeScript section](https://next-auth.js.org/getting-started/typescript) in the documentation. + +## Example + +### Add API Route + +```javascript +// pages/api/auth/[...nextauth].js +import NextAuth from "next-auth" +import AppleProvider from "next-auth/providers/apple" +import GoogleProvider from "next-auth/providers/google" +import EmailProvider from "next-auth/providers/email" + +export default NextAuth({ + secret: process.env.SECRET, + providers: [ + // OAuth authentication providers + AppleProvider({ + clientId: process.env.APPLE_ID, + clientSecret: process.env.APPLE_SECRET, + }), + GoogleProvider({ + clientId: process.env.GOOGLE_ID, + clientSecret: process.env.GOOGLE_SECRET, + }), + // Sign in with passwordless email link + EmailProvider({ + server: process.env.MAIL_SERVER, + from: "", + }), + ], +}) +``` + +### Add React Hook + +The `useSession()` React Hook in the NextAuth.js client is the easiest way to check if someone is signed in. + +```javascript +import { useSession, signIn, signOut } from "next-auth/react" + +export default function Component() { + const { data: session } = useSession() + if (session) { + return ( + <> + Signed in as {session.user.email}
+ + + ) + } + return ( + <> + Not signed in
+ + + ) +} +``` + +### Share/configure session state + +Use the `` to allow instances of `useSession()` to share the session object across components. It also takes care of keeping the session updated and synced between tabs/windows. + +```jsx title="pages/_app.js" +import { SessionProvider } from "next-auth/react" + +export default function App({ + Component, + pageProps: { session, ...pageProps }, +}) { + return ( + + + + ) +} +``` + +## Security + +If you think you have found a vulnerability (or not sure) in NextAuth.js or any of the related packages (i.e. Adapters), we ask you to have a read of our [Security Policy](https://github.com/nextauthjs/next-auth/blob/main/SECURITY.md) to reach out responsibly. Please do not open Pull Requests/Issues/Discussions before consulting with us. + +## Acknowledgments + +[NextAuth.js is made possible thanks to all of its contributors.](https://next-auth.js.org/contributors) + + + + +
+ +
+ +### Support + +We're happy to announce we've recently created an [OpenCollective](https://opencollective.com/nextauth) for individuals and companies looking to contribute financially to the project! + + + + + + + + + + + + + + +
+ + Vercel Logo +
+
Vercel

+ 🥉 Bronze Financial Sponsor
☁️ Infrastructure Support
+
+ + Prisma Logo +
+
Prisma

+ 🥉 Bronze Financial Sponsor +
+ + Clerk Logo +
+
Clerk

+ 🥉 Bronze Financial Sponsor +
+ + Lowdefy Logo +
+
Lowdefy

+ 🥉 Bronze Financial Sponsor +
+ + WorkOS Logo +
+
WorkOS

+ 🥉 Bronze Financial Sponsor +
+ + Checkly Logo +
+
Checkly

+ ☁️ Infrastructure Support +
+ + superblog Logo +
+
superblog

+ ☁️ Infrastructure Support +
+
+ + +## Contributing + +We're open to all community contributions! If you'd like to contribute in any way, please first read +our [Contributing Guide](https://github.com/nextauthjs/.github/blob/main/CONTRIBUTING.md). + +## License + +ISC diff --git a/packages/next-auth/config/babel.config.js b/packages/next-auth/config/babel.config.js new file mode 100644 index 00000000..965aef77 --- /dev/null +++ b/packages/next-auth/config/babel.config.js @@ -0,0 +1,62 @@ +// @ts-check +// We aim to have the same support as Next.js +// https://nextjs.org/docs/getting-started#system-requirements +// https://nextjs.org/docs/basic-features/supported-browsers-features + +/** @type {import("@babel/core").ConfigFunction} */ +module.exports = (api) => { + const isTest = api.env("test") + if (isTest) { + return { + presets: [ + "@babel/preset-env", + ["@babel/preset-react", { runtime: "automatic" }], + ["@babel/preset-typescript", { isTSX: true, allExtensions: true }], + ], + } + } + return { + presets: [ + ["@babel/preset-env", { targets: { node: 12 } }], + "@babel/preset-typescript", + ], + plugins: [ + "@babel/plugin-proposal-optional-catch-binding", + "@babel/plugin-transform-runtime", + ], + ignore: [ + "../src/**/__tests__/**", + "../src/adapters.ts", + "../src/providers/oauth-types.ts", + ], + comments: false, + overrides: [ + { + test: [ + "../src/react/index.tsx", + "../src/utils/logger.ts", + "../src/core/errors.ts", + "../src/client/**", + ], + presets: [ + ["@babel/preset-env", { targets: { ie: 11 } }], + ["@babel/preset-react", { runtime: "automatic" }], + ], + }, + { + test: ["../src/core/pages/*.tsx"], + presets: ["preact"], + plugins: [ + [ + "jsx-pragmatic", + { + module: "preact", + export: "h", + import: "h", + }, + ], + ], + }, + ], + } +} diff --git a/packages/next-auth/config/generate-providers.js b/packages/next-auth/config/generate-providers.js new file mode 100644 index 00000000..edd3c4fd --- /dev/null +++ b/packages/next-auth/config/generate-providers.js @@ -0,0 +1,18 @@ +const path = require("path") +const fs = require("fs") + +const providersPath = path.join(process.cwd(), "src/providers") + +const files = fs.readdirSync(providersPath, "utf8") + +const providers = files.map((file) => { + const strippedProviderName = file.substring(0, file.indexOf(".")) + return `"${strippedProviderName}"` +}) + +const result = ` +// THIS FILE IS AUTOGENERATED. DO NOT EDIT. +export type OAuthProviderType = + | ${providers.join("\n | ")}` + +fs.writeFileSync(path.join(providersPath, "oauth-types.ts"), result) diff --git a/packages/next-auth/config/jest-setup.js b/packages/next-auth/config/jest-setup.js new file mode 100644 index 00000000..54051de0 --- /dev/null +++ b/packages/next-auth/config/jest-setup.js @@ -0,0 +1,3 @@ +import "regenerator-runtime/runtime" +import "@testing-library/jest-dom" +import "whatwg-fetch" diff --git a/packages/next-auth/config/jest.config.js b/packages/next-auth/config/jest.config.js new file mode 100644 index 00000000..951c929d --- /dev/null +++ b/packages/next-auth/config/jest.config.js @@ -0,0 +1,43 @@ +const swcConfig = require("./swc.config") + +/** @type {import('jest').Config} */ +module.exports = { + projects: [ + { + displayName: "core", + testMatch: ["/tests/**/*.test.ts"], + rootDir: ".", + transform: { + "\\.(js|jsx|ts|tsx)$": ["@swc/jest", swcConfig], + }, + coveragePathIgnorePatterns: ["tests"], + testEnvironment: "@edge-runtime/jest-environment", + transformIgnorePatterns: ["node_modules/(?!uuid)/"], + /** @type {import("@edge-runtime/vm").EdgeVMOptions} */ + testEnvironmentOptions: { + codeGeneration: { + strings: true, + }, + }, + }, + { + displayName: "client", + testMatch: ["/src/client/**/*.test.js"], + setupFilesAfterEnv: ["./config/jest-setup.js"], + rootDir: ".", + transform: { + "\\.(js|jsx|ts|tsx)$": ["@swc/jest", swcConfig], + }, + testEnvironment: "jsdom", + coveragePathIgnorePatterns: ["__tests__"], + }, + ], + watchPlugins: [ + "jest-watch-typeahead/filename", + "jest-watch-typeahead/testname", + ], + collectCoverage: true, + coverageDirectory: "../coverage", + coverageReporters: ["html", "text-summary"], + collectCoverageFrom: ["src/**/*.(js|jsx|ts|tsx)"], +} diff --git a/packages/next-auth/config/postcss.config.js b/packages/next-auth/config/postcss.config.js new file mode 100644 index 00000000..c9110b1c --- /dev/null +++ b/packages/next-auth/config/postcss.config.js @@ -0,0 +1,7 @@ +module.exports = { + plugins: [ + require('autoprefixer'), + require('postcss-nested'), + require('cssnano')({ preset: 'default' }) + ] +} diff --git a/packages/next-auth/config/swc.config.js b/packages/next-auth/config/swc.config.js new file mode 100644 index 00000000..5aeeac2d --- /dev/null +++ b/packages/next-auth/config/swc.config.js @@ -0,0 +1,18 @@ +/** @type {import("@swc/core").Config} */ +module.exports = { + jsc: { + parser: { + syntax: "typescript", + tsx: true, + }, + transform: { + react: { + runtime: "automatic", + pragma: "React.createElement", + pragmaFrag: "React.Fragment", + throwIfNamespace: true, + useBuiltins: true, + }, + }, + }, +} diff --git a/packages/next-auth/config/wrap-css.js b/packages/next-auth/config/wrap-css.js new file mode 100644 index 00000000..d59d7892 --- /dev/null +++ b/packages/next-auth/config/wrap-css.js @@ -0,0 +1,17 @@ +// Serverless target in Next.js does not work if you try to read in files at runtime +// that are not JavaScript or JSON (e.g. CSS files). +// https://github.com/nextauthjs/next-auth/issues/281 +// +// To work around this issue, this script is a manual step that wraps CSS in a +// JavaScript file that has the compiled CSS embedded in it, and exports only +// a function that returns the CSS as a string. +const fs = require("fs") +const path = require("path") + +const pathToCss = path.join(__dirname, "../css/index.css") +const css = fs.readFileSync(pathToCss, "utf8") +const cssWithEscapedQuotes = css.replace(/"/gm, '\\"') + +const js = `module.exports = function() { return "${cssWithEscapedQuotes}" }` +const pathToCssJs = path.join(__dirname, "../css/index.js") +fs.writeFileSync(pathToCssJs, js) diff --git a/packages/next-auth/package.json b/packages/next-auth/package.json new file mode 100644 index 00000000..0394fa50 --- /dev/null +++ b/packages/next-auth/package.json @@ -0,0 +1,129 @@ +{ + "name": "next-auth", + "version": "4.22.1", + "description": "Authentication for Next.js", + "homepage": "https://next-auth.js.org", + "repository": "https://github.com/nextauthjs/next-auth.git", + "author": "Iain Collins ", + "contributors": [ + "Balázs Orbán ", + "Nico Domino ", + "Lluis Agusti ", + "Thang Huu Vu " + ], + "main": "index.js", + "module": "index.js", + "types": "index.d.ts", + "keywords": [ + "react", + "nodejs", + "oauth", + "jwt", + "oauth2", + "authentication", + "nextjs", + "csrf", + "oidc", + "nextauth" + ], + "exports": { + ".": "./index.js", + "./jwt": "./jwt/index.js", + "./react": "./react/index.js", + "./core": "./core/index.js", + "./next": "./next/index.js", + "./middleware": "./middleware.js", + "./client/_utils": "./client/_utils.js", + "./providers/*": "./providers/*.js" + }, + "scripts": { + "build": "pnpm clean && pnpm build:js && pnpm build:css", + "build:js": "pnpm clean && pnpm generate-providers && pnpm tsc --project tsconfig.json && babel --config-file ./config/babel.config.js src --out-dir . --extensions \".tsx,.ts,.js,.jsx\"", + "clean": "rm -rf coverage client css utils providers core jwt react next index.d.ts index.js adapters.d.ts middleware.d.ts middleware.js", + "build:css": "postcss --config config/postcss.config.js src/**/*.css --base src --dir . && node config/wrap-css.js", + "dev": "pnpm clean && pnpm generate-providers && concurrently \"pnpm watch:css\" \"pnpm watch:ts\"", + "watch:ts": "pnpm tsc --project tsconfig.dev.json", + "watch:css": "postcss --config config/postcss.config.js --watch src/**/*.css --base src --dir .", + "test": "jest --config ./config/jest.config.js", + "prepublishOnly": "pnpm build", + "generate-providers": "node ./config/generate-providers.js", + "lint": "eslint src config tests" + }, + "files": [ + "client", + "core", + "css", + "jwt", + "lib", + "next", + "providers", + "react", + "src", + "utils", + "*.d.ts*", + "*.js" + ], + "license": "ISC", + "dependencies": { + "@babel/runtime": "^7.20.13", + "@panva/hkdf": "^1.0.2", + "cookie": "^0.5.0", + "jose": "^4.11.4", + "oauth": "^0.9.15", + "openid-client": "^5.4.0", + "preact": "^10.6.3", + "preact-render-to-string": "^5.1.19", + "uuid": "^8.3.2" + }, + "peerDependencies": { + "next": "^12.2.5 || ^13", + "nodemailer": "^6.6.5", + "react": "^17.0.2 || ^18", + "react-dom": "^17.0.2 || ^18" + }, + "peerDependenciesMeta": { + "nodemailer": { + "optional": true + } + }, + "devDependencies": { + "@babel/cli": "^7.17.10", + "@babel/core": "^7.18.2", + "@babel/plugin-proposal-optional-catch-binding": "^7.16.7", + "@babel/plugin-transform-runtime": "^7.18.2", + "@babel/preset-env": "^7.18.2", + "@babel/preset-react": "^7.17.12", + "@babel/preset-typescript": "^7.17.12", + "@edge-runtime/jest-environment": "1.1.0-beta.35", + "@next-auth/tsconfig": "workspace:*", + "@swc/core": "^1.2.198", + "@swc/jest": "^0.2.21", + "@testing-library/dom": "^8.13.0", + "@testing-library/jest-dom": "^5.16.4", + "@testing-library/react": "^13.3.0", + "@testing-library/react-hooks": "^8.0.0", + "@testing-library/user-event": "^14.2.0", + "@types/jest": "^28.1.3", + "@types/node": "^17.0.42", + "@types/nodemailer": "^6.4.4", + "@types/oauth": "^0.9.1", + "@types/react": "18.0.37", + "@types/react-dom": "^18.0.6", + "autoprefixer": "^10.4.7", + "babel-plugin-jsx-pragmatic": "^1.0.2", + "babel-preset-preact": "^2.0.0", + "concurrently": "^7", + "cssnano": "^5.1.11", + "jest": "^28.1.1", + "jest-environment-jsdom": "^28.1.1", + "jest-watch-typeahead": "^1.1.0", + "msw": "^0.42.3", + "next": "13.3.0", + "postcss": "^8.4.14", + "postcss-cli": "^9.1.0", + "postcss-nested": "^5.0.6", + "react": "^18", + "react-dom": "^18", + "whatwg-fetch": "^3.6.2" + } +} diff --git a/packages/next-auth/provider-logos/apple-dark.svg b/packages/next-auth/provider-logos/apple-dark.svg new file mode 100644 index 00000000..60b1a36a --- /dev/null +++ b/packages/next-auth/provider-logos/apple-dark.svg @@ -0,0 +1,4 @@ + + Apple icon + + diff --git a/packages/next-auth/provider-logos/apple.svg b/packages/next-auth/provider-logos/apple.svg new file mode 100644 index 00000000..4d08570d --- /dev/null +++ b/packages/next-auth/provider-logos/apple.svg @@ -0,0 +1,4 @@ + + Apple icon + + diff --git a/packages/next-auth/provider-logos/atlassian-dark.svg b/packages/next-auth/provider-logos/atlassian-dark.svg new file mode 100644 index 00000000..9c41c735 --- /dev/null +++ b/packages/next-auth/provider-logos/atlassian-dark.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/packages/next-auth/provider-logos/atlassian.svg b/packages/next-auth/provider-logos/atlassian.svg new file mode 100644 index 00000000..37a5e7fd --- /dev/null +++ b/packages/next-auth/provider-logos/atlassian.svg @@ -0,0 +1,4 @@ + + + + diff --git a/packages/next-auth/provider-logos/auth0-dark.svg b/packages/next-auth/provider-logos/auth0-dark.svg new file mode 100644 index 00000000..1b4a7b6d --- /dev/null +++ b/packages/next-auth/provider-logos/auth0-dark.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/packages/next-auth/provider-logos/auth0.svg b/packages/next-auth/provider-logos/auth0.svg new file mode 100644 index 00000000..102518ef --- /dev/null +++ b/packages/next-auth/provider-logos/auth0.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/next-auth/provider-logos/azure-dark.svg b/packages/next-auth/provider-logos/azure-dark.svg new file mode 100644 index 00000000..fb3329d8 --- /dev/null +++ b/packages/next-auth/provider-logos/azure-dark.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/next-auth/provider-logos/azure.svg b/packages/next-auth/provider-logos/azure.svg new file mode 100644 index 00000000..9b29e54f --- /dev/null +++ b/packages/next-auth/provider-logos/azure.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/next-auth/provider-logos/battlenet-dark.svg b/packages/next-auth/provider-logos/battlenet-dark.svg new file mode 100644 index 00000000..58fe8c84 --- /dev/null +++ b/packages/next-auth/provider-logos/battlenet-dark.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/next-auth/provider-logos/battlenet.svg b/packages/next-auth/provider-logos/battlenet.svg new file mode 100644 index 00000000..299d5f72 --- /dev/null +++ b/packages/next-auth/provider-logos/battlenet.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/next-auth/provider-logos/box-dark.svg b/packages/next-auth/provider-logos/box-dark.svg new file mode 100644 index 00000000..20a66a6a --- /dev/null +++ b/packages/next-auth/provider-logos/box-dark.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/packages/next-auth/provider-logos/box.svg b/packages/next-auth/provider-logos/box.svg new file mode 100644 index 00000000..da4cc596 --- /dev/null +++ b/packages/next-auth/provider-logos/box.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/packages/next-auth/provider-logos/cognito.svg b/packages/next-auth/provider-logos/cognito.svg new file mode 100644 index 00000000..012dc5a4 --- /dev/null +++ b/packages/next-auth/provider-logos/cognito.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/packages/next-auth/provider-logos/discord-dark.svg b/packages/next-auth/provider-logos/discord-dark.svg new file mode 100644 index 00000000..49f14c27 --- /dev/null +++ b/packages/next-auth/provider-logos/discord-dark.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/next-auth/provider-logos/discord.svg b/packages/next-auth/provider-logos/discord.svg new file mode 100644 index 00000000..b313eeb9 --- /dev/null +++ b/packages/next-auth/provider-logos/discord.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/next-auth/provider-logos/facebook-dark.svg b/packages/next-auth/provider-logos/facebook-dark.svg new file mode 100644 index 00000000..db842250 --- /dev/null +++ b/packages/next-auth/provider-logos/facebook-dark.svg @@ -0,0 +1,4 @@ + + + + diff --git a/packages/next-auth/provider-logos/facebook.svg b/packages/next-auth/provider-logos/facebook.svg new file mode 100644 index 00000000..24434914 --- /dev/null +++ b/packages/next-auth/provider-logos/facebook.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/packages/next-auth/provider-logos/foursquare-dark.svg b/packages/next-auth/provider-logos/foursquare-dark.svg new file mode 100644 index 00000000..ffe01fbf --- /dev/null +++ b/packages/next-auth/provider-logos/foursquare-dark.svg @@ -0,0 +1,18 @@ + + + + + + + + + diff --git a/packages/next-auth/provider-logos/foursquare.svg b/packages/next-auth/provider-logos/foursquare.svg new file mode 100644 index 00000000..5f63b452 --- /dev/null +++ b/packages/next-auth/provider-logos/foursquare.svg @@ -0,0 +1,18 @@ + + + + + + + + + diff --git a/packages/next-auth/provider-logos/freshbooks-dark.svg b/packages/next-auth/provider-logos/freshbooks-dark.svg new file mode 100644 index 00000000..c673c4d2 --- /dev/null +++ b/packages/next-auth/provider-logos/freshbooks-dark.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/next-auth/provider-logos/freshbooks.svg b/packages/next-auth/provider-logos/freshbooks.svg new file mode 100644 index 00000000..ff80db28 --- /dev/null +++ b/packages/next-auth/provider-logos/freshbooks.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/next-auth/provider-logos/github-dark.svg b/packages/next-auth/provider-logos/github-dark.svg new file mode 100644 index 00000000..41128ce9 --- /dev/null +++ b/packages/next-auth/provider-logos/github-dark.svg @@ -0,0 +1,4 @@ + + GitHub icon + + diff --git a/packages/next-auth/provider-logos/github.svg b/packages/next-auth/provider-logos/github.svg new file mode 100644 index 00000000..a6f58d97 --- /dev/null +++ b/packages/next-auth/provider-logos/github.svg @@ -0,0 +1,4 @@ + + GitHub dark icon + + diff --git a/packages/next-auth/provider-logos/gitlab-dark.svg b/packages/next-auth/provider-logos/gitlab-dark.svg new file mode 100644 index 00000000..a9d45541 --- /dev/null +++ b/packages/next-auth/provider-logos/gitlab-dark.svg @@ -0,0 +1,8 @@ + + + + + + diff --git a/packages/next-auth/provider-logos/gitlab.svg b/packages/next-auth/provider-logos/gitlab.svg new file mode 100644 index 00000000..3b684907 --- /dev/null +++ b/packages/next-auth/provider-logos/gitlab.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/packages/next-auth/provider-logos/google.svg b/packages/next-auth/provider-logos/google.svg new file mode 100644 index 00000000..60d0ec13 --- /dev/null +++ b/packages/next-auth/provider-logos/google.svg @@ -0,0 +1,7 @@ + + Google icon + + + + + diff --git a/packages/next-auth/provider-logos/hubspot-dark.svg b/packages/next-auth/provider-logos/hubspot-dark.svg new file mode 100644 index 00000000..e8ef5e7f --- /dev/null +++ b/packages/next-auth/provider-logos/hubspot-dark.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/next-auth/provider-logos/hubspot.svg b/packages/next-auth/provider-logos/hubspot.svg new file mode 100644 index 00000000..3ab02c3b --- /dev/null +++ b/packages/next-auth/provider-logos/hubspot.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/next-auth/provider-logos/instagram.svg b/packages/next-auth/provider-logos/instagram.svg new file mode 100644 index 00000000..9801b04b --- /dev/null +++ b/packages/next-auth/provider-logos/instagram.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/packages/next-auth/provider-logos/keycloak.svg b/packages/next-auth/provider-logos/keycloak.svg new file mode 100644 index 00000000..4a558aef --- /dev/null +++ b/packages/next-auth/provider-logos/keycloak.svg @@ -0,0 +1,260 @@ + + + + + + + + + + + + keycloak_deliverables + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/next-auth/provider-logos/line.svg b/packages/next-auth/provider-logos/line.svg new file mode 100644 index 00000000..afbd2758 --- /dev/null +++ b/packages/next-auth/provider-logos/line.svg @@ -0,0 +1,6 @@ + + Line icon + + + + diff --git a/packages/next-auth/provider-logos/linkedin-dark.svg b/packages/next-auth/provider-logos/linkedin-dark.svg new file mode 100644 index 00000000..3240302d --- /dev/null +++ b/packages/next-auth/provider-logos/linkedin-dark.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/packages/next-auth/provider-logos/linkedin.svg b/packages/next-auth/provider-logos/linkedin.svg new file mode 100644 index 00000000..1bc626bc --- /dev/null +++ b/packages/next-auth/provider-logos/linkedin.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/packages/next-auth/provider-logos/mailchimp-dark.svg b/packages/next-auth/provider-logos/mailchimp-dark.svg new file mode 100644 index 00000000..adf1fa2b --- /dev/null +++ b/packages/next-auth/provider-logos/mailchimp-dark.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/next-auth/provider-logos/mailchimp.svg b/packages/next-auth/provider-logos/mailchimp.svg new file mode 100644 index 00000000..27a2f9bc --- /dev/null +++ b/packages/next-auth/provider-logos/mailchimp.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/next-auth/provider-logos/okta-dark.svg b/packages/next-auth/provider-logos/okta-dark.svg new file mode 100644 index 00000000..a976f88b --- /dev/null +++ b/packages/next-auth/provider-logos/okta-dark.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/next-auth/provider-logos/okta.svg b/packages/next-auth/provider-logos/okta.svg new file mode 100644 index 00000000..2321a153 --- /dev/null +++ b/packages/next-auth/provider-logos/okta.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/next-auth/provider-logos/patreon.svg b/packages/next-auth/provider-logos/patreon.svg new file mode 100644 index 00000000..4fa72b52 --- /dev/null +++ b/packages/next-auth/provider-logos/patreon.svg @@ -0,0 +1,4 @@ + + + + diff --git a/packages/next-auth/provider-logos/slack.svg b/packages/next-auth/provider-logos/slack.svg new file mode 100644 index 00000000..b80883e7 --- /dev/null +++ b/packages/next-auth/provider-logos/slack.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/packages/next-auth/provider-logos/spotify.svg b/packages/next-auth/provider-logos/spotify.svg new file mode 100644 index 00000000..2421491e --- /dev/null +++ b/packages/next-auth/provider-logos/spotify.svg @@ -0,0 +1,4 @@ + + + + diff --git a/packages/next-auth/provider-logos/todoist.svg b/packages/next-auth/provider-logos/todoist.svg new file mode 100644 index 00000000..e229dc2c --- /dev/null +++ b/packages/next-auth/provider-logos/todoist.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/packages/next-auth/provider-logos/trakt-dark.svg b/packages/next-auth/provider-logos/trakt-dark.svg new file mode 100644 index 00000000..9722816d --- /dev/null +++ b/packages/next-auth/provider-logos/trakt-dark.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + diff --git a/packages/next-auth/provider-logos/trakt.svg b/packages/next-auth/provider-logos/trakt.svg new file mode 100644 index 00000000..5cb7e1fe --- /dev/null +++ b/packages/next-auth/provider-logos/trakt.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + diff --git a/packages/next-auth/provider-logos/twitch-dark.svg b/packages/next-auth/provider-logos/twitch-dark.svg new file mode 100644 index 00000000..41488e9d --- /dev/null +++ b/packages/next-auth/provider-logos/twitch-dark.svg @@ -0,0 +1,4 @@ + + + + diff --git a/packages/next-auth/provider-logos/twitch.svg b/packages/next-auth/provider-logos/twitch.svg new file mode 100644 index 00000000..8c08a260 --- /dev/null +++ b/packages/next-auth/provider-logos/twitch.svg @@ -0,0 +1,4 @@ + + + + diff --git a/packages/next-auth/provider-logos/twitter-dark.svg b/packages/next-auth/provider-logos/twitter-dark.svg new file mode 100644 index 00000000..07f05a86 --- /dev/null +++ b/packages/next-auth/provider-logos/twitter-dark.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/next-auth/provider-logos/twitter.svg b/packages/next-auth/provider-logos/twitter.svg new file mode 100644 index 00000000..35e715f2 --- /dev/null +++ b/packages/next-auth/provider-logos/twitter.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/next-auth/provider-logos/vk-dark.svg b/packages/next-auth/provider-logos/vk-dark.svg new file mode 100644 index 00000000..6ef4ef9e --- /dev/null +++ b/packages/next-auth/provider-logos/vk-dark.svg @@ -0,0 +1,4 @@ + + + + diff --git a/packages/next-auth/provider-logos/vk.svg b/packages/next-auth/provider-logos/vk.svg new file mode 100644 index 00000000..f567c75c --- /dev/null +++ b/packages/next-auth/provider-logos/vk.svg @@ -0,0 +1,4 @@ + + + + diff --git a/packages/next-auth/provider-logos/wikimedia-dark.svg b/packages/next-auth/provider-logos/wikimedia-dark.svg new file mode 100644 index 00000000..55de1b63 --- /dev/null +++ b/packages/next-auth/provider-logos/wikimedia-dark.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/packages/next-auth/provider-logos/wikimedia.svg b/packages/next-auth/provider-logos/wikimedia.svg new file mode 100644 index 00000000..3ae4b1ba --- /dev/null +++ b/packages/next-auth/provider-logos/wikimedia.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/packages/next-auth/provider-logos/workos-dark.svg b/packages/next-auth/provider-logos/workos-dark.svg new file mode 100644 index 00000000..b9047adc --- /dev/null +++ b/packages/next-auth/provider-logos/workos-dark.svg @@ -0,0 +1,4 @@ + + + + diff --git a/packages/next-auth/provider-logos/workos.svg b/packages/next-auth/provider-logos/workos.svg new file mode 100644 index 00000000..42f799f2 --- /dev/null +++ b/packages/next-auth/provider-logos/workos.svg @@ -0,0 +1,4 @@ + + + + diff --git a/packages/next-auth/src/adapters.ts b/packages/next-auth/src/adapters.ts new file mode 100644 index 00000000..b4389f2b --- /dev/null +++ b/packages/next-auth/src/adapters.ts @@ -0,0 +1,130 @@ +import { Account, User, Awaitable } from "." + +export interface AdapterUser extends User { + id: string + email: string + emailVerified: Date | null +} + +export interface AdapterAccount extends Account { + userId: string +} + +export interface AdapterSession { + /** A randomly generated value that is used to get hold of the session. */ + sessionToken: string + /** Used to connect the session to a particular user */ + userId: string + expires: Date +} + +export interface VerificationToken { + identifier: string + expires: Date + token: string +} + +/** + * Using a custom adapter you can connect to any database backend or even several different databases. + * Custom adapters created and maintained by our community can be found in the adapters repository. + * Feel free to add a custom adapter from your project to the repository, + * or even become a maintainer of a certain adapter. + * Custom adapters can still be created and used in a project without being added to the repository. + * + * **Required methods** + * + * _(These methods are required for all sign in flows)_ + * - `createUser` + * - `getUser` + * - `getUserByEmail` + * - `getUserByAccount` + * - `linkAccount` + * - `createSession` + * - `getSessionAndUser` + * - `updateSession` + * - `deleteSession` + * - `updateUser` + * + * _(Required to support email / passwordless sign in)_ + * + * - `createVerificationToken` + * - `useVerificationToken` + * + * **Unimplemented methods** + * + * _(These methods will be required in a future release, but are not yet invoked)_ + * - `deleteUser` + * - `unlinkAccount` + * + * [Adapters Overview](https://next-auth.js.org/adapters/overview) | + * [Create a custom adapter](https://next-auth.js.org/tutorials/creating-a-database-adapter) + */ +export type Adapter = 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 & Pick) => 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/next-auth/src/client/__tests__/client-provider.test.js b/packages/next-auth/src/client/__tests__/client-provider.test.js new file mode 100644 index 00000000..e7dac687 --- /dev/null +++ b/packages/next-auth/src/client/__tests__/client-provider.test.js @@ -0,0 +1,188 @@ +import { rest } from "msw" +import { render, screen, waitFor } from "@testing-library/react" +import { server, mockSession } from "./helpers/mocks" +import { printFetchCalls } from "./helpers/utils" +import { SessionProvider, useSession, signOut, getSession } from "../../react" + +const origDocumentVisibility = document.visibilityState +const fetchSpy = jest.spyOn(global, "fetch") + +beforeAll(() => { + server.listen() +}) + +afterEach(() => { + server.resetHandlers() + changeTabVisibility(origDocumentVisibility) + fetchSpy.mockClear() +}) + +afterAll(() => { + server.close() +}) + +test("fetches the session once and re-uses it for different consumers", async () => { + render() + + expect(screen.getByTestId("session-1")).toHaveTextContent("loading") + expect(screen.getByTestId("session-2")).toHaveTextContent("loading") + + return waitFor(() => { + expect(fetchSpy).toHaveBeenCalledTimes(1) + + expect(fetchSpy).toHaveBeenCalledWith( + "/api/auth/session", + expect.anything() + ) + + const session1 = screen.getByTestId("session-1").textContent + const session2 = screen.getByTestId("session-2").textContent + + expect(session1).toEqual(session2) + }) +}) + +test("when there's an existing session, it won't try to fetch a new one straightaway", async () => { + render() + + expect(fetchSpy).not.toHaveBeenCalled() +}) + +test("will refetch the session when the browser tab becomes active again", async () => { + render() + + expect(fetchSpy).not.toHaveBeenCalled() + + // Hide the current tab + changeTabVisibility("hidden") + + // Given the current tab got hidden, it should not attempt to re-fetch the session + expect(fetchSpy).not.toHaveBeenCalled() + + // Make the tab again visible + changeTabVisibility("visible") + + // Given the user made the tab visible again, now attempts to sync and re-fetch the session + return waitFor(() => { + expect(fetchSpy).toHaveBeenCalledTimes(1) + expect(fetchSpy).toHaveBeenCalledWith( + "/api/auth/session", + expect.anything() + ) + }) +}) + +test("will refetch the session if told to do so programmatically from another window", async () => { + render() + + expect(fetchSpy).not.toHaveBeenCalled() + + // Hide the current tab + changeTabVisibility("hidden") + + // Given the current tab got hidden, it should not attempt to re-fetch the session + expect(fetchSpy).not.toHaveBeenCalled() + + // simulate sign-out triggered by another tab + signOut({ redirect: false }) + + // Given signed out in another tab, it attempts to sync and re-fetch the session + return waitFor(() => { + expect(fetchSpy).toHaveBeenCalledWith( + "/api/auth/session", + expect.anything() + ) + + // We should have a call to sign-out and a call to refetch the session accordingly + expect(printFetchCalls(fetchSpy.mock.calls)).toMatchInlineSnapshot(` + Array [ + "GET /api/auth/csrf", + "POST /api/auth/signout", + "GET /api/auth/session", + ] + `) + }) +}) + +test("allows to customize how often the session will be re-fetched through polling", () => { + jest.useFakeTimers() + + render() + + // we provided a mock session so it shouldn't try to fetch a new one + expect(fetchSpy).not.toHaveBeenCalled() + + jest.advanceTimersByTime(1000) + + expect(fetchSpy).toHaveBeenCalledTimes(1) + expect(fetchSpy).toHaveBeenCalledWith("/api/auth/session", expect.anything()) + + jest.advanceTimersByTime(1000) + + // it should have tried to refetch the session, hence counting 2 calls to the session endpoint + expect(fetchSpy).toHaveBeenCalledTimes(2) + expect(printFetchCalls(fetchSpy.mock.calls)).toMatchInlineSnapshot(` + Array [ + "GET /api/auth/session", + "GET /api/auth/session", + ] + `) +}) + +test("allows to customize the URL for session fetching", async () => { + const myPath = "/api/v1/auth" + + server.use( + rest.get(`${myPath}/session`, (req, res, ctx) => + res(ctx.status(200), ctx.json(mockSession)) + ) + ) + + render() + + // there's an existing session so it should not try to fetch a new one + expect(fetchSpy).not.toHaveBeenCalled() + + // force a session refetch across all clients... + getSession() + + return waitFor(() => { + expect(fetchSpy).toHaveBeenCalledTimes(1) + expect(fetchSpy).toHaveBeenCalledWith( + `${myPath}/session`, + expect.anything() + ) + }) +}) + +function ProviderFlow(props) { + return ( + + + + + ) +} + +function SessionConsumer({ testId = 1, ...rest }) { + const { data: session, status } = useSession(rest) + + return ( +
+ {status === "loading" ? "loading" : JSON.stringify(session)} +
+ ) +} + +function changeTabVisibility(status) { + const visibleStates = ["visible", "hidden"] + + if (!visibleStates.includes(status)) return + + Object.defineProperty(document, "visibilityState", { + configurable: true, + value: status, + }) + + document.dispatchEvent(new Event("visibilitychange")) +} diff --git a/packages/next-auth/src/client/__tests__/csrf.test.js b/packages/next-auth/src/client/__tests__/csrf.test.js new file mode 100644 index 00000000..1d6b1261 --- /dev/null +++ b/packages/next-auth/src/client/__tests__/csrf.test.js @@ -0,0 +1,104 @@ +import { useState } from "react" +import userEvent from "@testing-library/user-event" +import { render, screen, waitFor } from "@testing-library/react" +import { server, mockCSRFToken } from "./helpers/mocks" +import logger from "../../utils/logger" +import { getCsrfToken } from "../../react" +import { rest } from "msw" + +jest.mock("../../utils/logger", () => ({ + __esModule: true, + default: { + warn: jest.fn(), + debug: jest.fn(), + error: jest.fn(), + }, + proxyLogger(logger) { + return logger + }, +})) + +beforeAll(() => { + server.listen() +}) + +afterEach(() => { + server.resetHandlers() + jest.clearAllMocks() +}) + +afterAll(() => { + server.close() +}) + +test("returns the Cross Site Request Forgery Token (CSRF Token) required to make POST requests", async () => { + render() + + userEvent.click(screen.getByRole("button")) + + await waitFor(() => { + expect(screen.getByTestId("csrf-result").textContent).toEqual( + mockCSRFToken.csrfToken + ) + }) +}) + +test("when there's no CSRF token returned, it'll reflect that", async () => { + server.use( + rest.get("*/api/auth/csrf", (req, res, ctx) => + res( + ctx.status(200), + ctx.json({ + ...mockCSRFToken, + csrfToken: null, + }) + ) + ) + ) + + render() + + userEvent.click(screen.getByRole("button")) + + await waitFor(() => { + expect(screen.getByTestId("csrf-result").textContent).toBe("null-response") + }) +}) + +test("when the fetch fails it'll throw a client fetch error", async () => { + server.use( + rest.get("*/api/auth/csrf", (req, res, ctx) => + res(ctx.status(500), ctx.text("some error happened")) + ) + ) + + render() + + userEvent.click(screen.getByRole("button")) + + await waitFor(() => { + expect(logger.error).toHaveBeenCalledTimes(1) + expect(logger.error).toBeCalledWith("CLIENT_FETCH_ERROR", { + url: "/api/auth/csrf", + error: new SyntaxError("Unexpected token s in JSON at position 0"), + }) + }) +}) + +function CSRFFlow() { + const [response, setResponse] = useState() + + async function handleCSRF() { + const result = await getCsrfToken() + setResponse(result) + } + + return ( + <> +

+ {response === null ? "null-response" : response || "no response"} +

+ + + ) +} diff --git a/packages/next-auth/src/client/__tests__/helpers/mocks.js b/packages/next-auth/src/client/__tests__/helpers/mocks.js new file mode 100644 index 00000000..79c532fd --- /dev/null +++ b/packages/next-auth/src/client/__tests__/helpers/mocks.js @@ -0,0 +1,90 @@ +import { setupServer } from "msw/node" +import { rest } from "msw" +import { randomBytes } from "crypto" + +export const mockSession = { + ok: true, + user: { + image: null, + name: "John", + email: "john@email.com", + }, + expires: 123213139, +} + +export const mockProviders = { + ok: true, + github: { + id: "github", + name: "Github", + type: "oauth", + signinUrl: "path/to/signin", + callbackUrl: "path/to/callback", + }, + credentials: { + id: "credentials", + name: "Credentials", + type: "credentials", + authorize: null, + credentials: null, + }, + email: { + id: "email", + type: "email", + name: "Email", + }, +} + +export const mockCSRFToken = { + ok: true, + csrfToken: randomBytes(32).toString("hex"), +} + +export const mockGithubResponse = { + ok: true, + status: 200, + url: "https://path/to/github/url", +} + +export const mockCredentialsResponse = { + ok: true, + status: 200, + url: "https://path/to/credentials/url", +} + +export const mockEmailResponse = { + ok: true, + status: 200, + url: "https://path/to/email/url", +} + +export const mockSignOutResponse = { + ok: true, + status: 200, + url: "https://path/to/signout/url", +} + +export const server = setupServer( + rest.post("*/api/auth/signout", (req, res, ctx) => + res(ctx.status(200), ctx.json(mockSignOutResponse)) + ), + rest.get("*/api/auth/session", (req, res, ctx) => + res(ctx.status(200), ctx.json(mockSession)) + ), + rest.get("*/api/auth/csrf", (req, res, ctx) => + res(ctx.status(200), ctx.json(mockCSRFToken)) + ), + rest.get("*/api/auth/providers", (req, res, ctx) => + res(ctx.status(200), ctx.json(mockProviders)) + ), + rest.post("*/api/auth/signin/github", (req, res, ctx) => + res(ctx.status(200), ctx.json(mockGithubResponse)) + ), + rest.post("*/api/auth/callback/credentials", (req, res, ctx) => + res(ctx.status(200), ctx.json(mockCredentialsResponse)) + ), + rest.post("*/api/auth/signin/email", (req, res, ctx) => + res(ctx.status(200), ctx.json(mockEmailResponse)) + ), + rest.post("*/api/auth/_log", (req, res, ctx) => res(ctx.status(200))) +) diff --git a/packages/next-auth/src/client/__tests__/helpers/utils.js b/packages/next-auth/src/client/__tests__/helpers/utils.js new file mode 100644 index 00000000..df2844a1 --- /dev/null +++ b/packages/next-auth/src/client/__tests__/helpers/utils.js @@ -0,0 +1,14 @@ +export function getBroadcastEvents() { + return window.localStorage.setItem.mock.calls + .filter((call) => call[0] === "nextauth.message") + .map(([eventName, value]) => { + const { timestamp, ...rest } = JSON.parse(value) + return { eventName, value: rest } + }) +} + +export function printFetchCalls(mockCalls) { + return mockCalls.map(([path, { method = "GET" }]) => { + return `${method.toUpperCase()} ${path}` + }) +} diff --git a/packages/next-auth/src/client/__tests__/providers.test.js b/packages/next-auth/src/client/__tests__/providers.test.js new file mode 100644 index 00000000..45d05508 --- /dev/null +++ b/packages/next-auth/src/client/__tests__/providers.test.js @@ -0,0 +1,84 @@ +import { useState } from "react" +import userEvent from "@testing-library/user-event" +import { render, screen, waitFor } from "@testing-library/react" +import { server, mockProviders } from "./helpers/mocks" +import { getProviders } from "../../react" +import logger from "../../utils/logger" +import { rest } from "msw" + +jest.mock("../../utils/logger", () => ({ + __esModule: true, + default: { + warn: jest.fn(), + debug: jest.fn(), + error: jest.fn(), + }, + proxyLogger(logger) { + return logger + }, +})) + +beforeAll(() => { + server.listen() +}) + +afterEach(() => { + server.resetHandlers() + jest.clearAllMocks() +}) + +afterAll(() => { + server.close() +}) + +test("when called it'll return the currently configured providers for sign in", async () => { + render() + + userEvent.click(screen.getByRole("button")) + + await waitFor(() => { + expect(screen.getByTestId("providers-result").textContent).toEqual( + JSON.stringify(mockProviders) + ) + }) +}) + +test("when failing to fetch the providers, it'll log the error", async () => { + server.use( + rest.get("*/api/auth/providers", (req, res, ctx) => + res(ctx.status(500), ctx.text("some error happened")) + ) + ) + + render() + + userEvent.click(screen.getByRole("button")) + + await waitFor(() => { + expect(logger.error).toHaveBeenCalledTimes(1) + expect(logger.error).toBeCalledWith("CLIENT_FETCH_ERROR", { + url: "/api/auth/providers", + error: new SyntaxError("Unexpected token s in JSON at position 0"), + }) + }) +}) + +function ProvidersFlow() { + const [response, setResponse] = useState() + + async function handleGerProviders() { + const result = await getProviders() + setResponse(result) + } + + return ( + <> +

+ {response === null + ? "null-response" + : JSON.stringify(response) || "no response"} +

+ + + ) +} diff --git a/packages/next-auth/src/client/__tests__/session.test.js b/packages/next-auth/src/client/__tests__/session.test.js new file mode 100644 index 00000000..4940f81b --- /dev/null +++ b/packages/next-auth/src/client/__tests__/session.test.js @@ -0,0 +1,97 @@ +import { render, screen, waitFor } from "@testing-library/react" +import { rest } from "msw" +import { server, mockSession } from "./helpers/mocks" +import logger from "../../utils/logger" +import { useState, useEffect } from "react" +import { getSession } from "../../react" +import { getBroadcastEvents } from "./helpers/utils" + +jest.mock("../../utils/logger", () => ({ + __esModule: true, + default: { + warn: jest.fn(), + debug: jest.fn(), + error: jest.fn(), + }, + proxyLogger(logger) { + return logger + }, +})) + +beforeAll(() => server.listen()) + +beforeEach(() => { + // eslint-disable-next-line no-proto + jest.spyOn(window.localStorage.__proto__, "setItem") +}) + +afterEach(() => { + server.resetHandlers() + jest.clearAllMocks() +}) + +afterAll(() => { + server.close() +}) + +test("if it can fetch the session, it should store it in `localStorage`", async () => { + render() + + // In the start, there is no session + const noSession = await screen.findByText("No session") + expect(noSession).toBeInTheDocument() + + // After we fetched the session, it should have been rendered by `` + const session = await screen.findByText(new RegExp(mockSession.user.name)) + expect(session).toBeInTheDocument() + + const broadcastCalls = getBroadcastEvents() + const [broadcastedEvent] = broadcastCalls + + expect(broadcastCalls).toHaveLength(1) + expect(broadcastCalls).toHaveLength(1) + expect(broadcastedEvent.eventName).toBe("nextauth.message") + expect(broadcastedEvent.value).toStrictEqual({ + data: { + trigger: "getSession", + }, + event: "session", + }) +}) + +test("if there's an error fetching the session, it should log it", async () => { + server.use( + rest.get("*/api/auth/session", (req, res, ctx) => { + return res(ctx.status(500), ctx.body("Server error")) + }) + ) + + render() + + await waitFor(() => { + expect(logger.error).toHaveBeenCalledTimes(1) + expect(logger.error).toBeCalledWith("CLIENT_FETCH_ERROR", { + url: "/api/auth/session", + error: new SyntaxError("Unexpected token S in JSON at position 0"), + }) + }) +}) + +function SessionFlow() { + const [session, setSession] = useState(null) + useEffect(() => { + async function fetchUserSession() { + try { + const result = await getSession() + setSession(result) + } catch (e) { + console.error(e) + } + } + fetchUserSession() + }, []) + + if (session) return
{JSON.stringify(session, null, 2)}
+ + return

No session

+} diff --git a/packages/next-auth/src/client/__tests__/sign-in.test.js b/packages/next-auth/src/client/__tests__/sign-in.test.js new file mode 100644 index 00000000..0422fe90 --- /dev/null +++ b/packages/next-auth/src/client/__tests__/sign-in.test.js @@ -0,0 +1,290 @@ +import { useState } from "react" +import userEvent from "@testing-library/user-event" +import { render, screen, waitFor } from "@testing-library/react" +import logger from "../../utils/logger" +import { + server, + mockCredentialsResponse, + mockEmailResponse, + mockGithubResponse, +} from "./helpers/mocks" +import { signIn } from "../../react" +import { rest } from "msw" + +const { location } = window + +jest.mock("../../utils/logger", () => ({ + __esModule: true, + default: { + warn: jest.fn(), + debug: jest.fn(), + error: jest.fn(), + }, + proxyLogger(logger) { + return logger + }, +})) + +beforeAll(() => { + server.listen() + + let _href = window.location.href + // Allows to mutate `window.location`... + delete window.location + + window.location = { + reload: jest.fn(), + } + Object.defineProperty(window.location, "href", { + get: () => _href, + // whatwg-fetch or whatwg-url does not seem to work with relative URLs + set: (href) => { + _href = href.startsWith("/") ? `http://localhost${href}` : href + return _href + }, + }) +}) + +beforeEach(() => { + jest.clearAllMocks() + server.resetHandlers() +}) + +afterAll(() => { + window.location = location + server.close() +}) + +const callbackUrl = "https://redirects/to" + +test.each` + provider | type + ${""} | ${"no"} + ${"foo"} | ${"unknown"} +`( + "if $type provider, it redirects to the default sign-in page", + async ({ provider }) => { + render() + + userEvent.click(screen.getByRole("button")) + + await waitFor(() => { + expect(window.location.href).toBe( + `http://localhost/api/auth/signin?${new URLSearchParams({ + callbackUrl, + })}` + ) + }) + } +) + +test.each` + provider | type + ${""} | ${"no"} + ${"foo"} | ${"unknown"} +`( + "if $type provider supplied and no callback URL, redirects using the current location", + async ({ provider }) => { + render() + + const callbackUrl = window.location.href + userEvent.click(screen.getByRole("button")) + + await waitFor(() => { + expect(window.location.href).toBe( + `http://localhost/api/auth/signin?${new URLSearchParams({ + callbackUrl, + })}` + ) + }) + } +) + +test.each` + provider | mockUrl + ${`email`} | ${mockEmailResponse.url} + ${`credentials`} | ${mockCredentialsResponse.url} +`( + "$provider provider redirects if `redirect` is `true`", + async ({ provider, mockUrl }) => { + render() + + userEvent.click(screen.getByRole("button")) + + await waitFor(() => { + expect(window.location.href).toBe(mockUrl) + }) + } +) + +test("redirection can't be stopped using an oauth provider", async () => { + render( + + ) + + userEvent.click(screen.getByRole("button")) + + await waitFor(() => { + expect(window.location.href).toBe(mockGithubResponse.url) + }) +}) + +test("redirection can be stopped using the 'credentials' provider", async () => { + render( + + ) + + userEvent.click(screen.getByRole("button")) + + await waitFor(() => { + expect(window.location.href).not.toBe(mockCredentialsResponse.url) + + expect(screen.getByTestId("signin-result").textContent).not.toBe( + "no response" + ) + }) + + // snapshot the expected return shape from `signIn` + expect(JSON.parse(screen.getByTestId("signin-result").textContent)) + .toMatchInlineSnapshot(` + Object { + "error": null, + "ok": true, + "status": 200, + "url": "https://path/to/credentials/url", + } + `) +}) + +test("redirection can be stopped using the 'email' provider", async () => { + render( + + ) + + userEvent.click(screen.getByRole("button")) + + await waitFor(() => { + expect(window.location.href).not.toBe(mockEmailResponse.url) + + expect(screen.getByTestId("signin-result").textContent).not.toBe( + "no response" + ) + }) + + // snapshot the expected return shape from `signIn` oauth + expect(JSON.parse(screen.getByTestId("signin-result").textContent)) + .toMatchInlineSnapshot(` + Object { + "error": null, + "ok": true, + "status": 200, + "url": "https://path/to/email/url", + } + `) +}) + +test("if callback URL contains a hash we force a window reload when re-directing", async () => { + const mockUrlWithHash = "https://path/to/email/url#foo-bar-baz" + + server.use( + rest.post("*/api/auth/signin/email", (req, res, ctx) => { + return res( + ctx.status(200), + ctx.json({ + ...mockEmailResponse, + url: mockUrlWithHash, + }) + ) + }) + ) + + render() + + userEvent.click(screen.getByRole("button")) + + await waitFor(() => { + expect(window.location.href).toBe(mockUrlWithHash) + // the browser will not refresh the page if the redirect URL contains a hash, hence we force it on the client, see #1289 + expect(window.location.reload).toHaveBeenCalledTimes(1) + }) +}) + +test("params are propagated to the signin URL when supplied", async () => { + let matchedParams = "" + const authParams = "foo=bar&bar=foo" + + server.use( + rest.post("*/auth/signin/github", (req, res, ctx) => { + matchedParams = req.url.search + return res(ctx.status(200), ctx.json(mockGithubResponse)) + }) + ) + + render() + + userEvent.click(screen.getByRole("button")) + + await waitFor(() => { + expect(matchedParams).toEqual(`?${authParams}`) + }) +}) + +test("when it fails to fetch the providers, it redirected back to signin page", async () => { + const errorMsg = "Error when retrieving providers" + + server.use( + rest.get("*/api/auth/providers", (req, res, ctx) => + res(ctx.status(500), ctx.json(errorMsg)) + ) + ) + + render() + + userEvent.click(screen.getByRole("button")) + + await waitFor(() => { + expect(window.location.href).toBe(`http://localhost/api/auth/error`) + + expect(logger.error).toHaveBeenCalledTimes(1) + expect(logger.error).toBeCalledWith("CLIENT_FETCH_ERROR", { + error: "Error when retrieving providers", + url: "/api/auth/providers", + }) + }) +}) + +function SignInFlow({ + providerId, + callbackUrl, + redirect = true, + authorizationParams = {}, +}) { + const [response, setResponse] = useState(null) + + async function handleSignIn() { + const result = await signIn( + providerId, + { callbackUrl, redirect }, + authorizationParams + ) + + setResponse(result) + } + + return ( + <> +

+ {response ? JSON.stringify(response) : "no response"} +

+ + + ) +} diff --git a/packages/next-auth/src/client/__tests__/sign-out.test.js b/packages/next-auth/src/client/__tests__/sign-out.test.js new file mode 100644 index 00000000..508ed254 --- /dev/null +++ b/packages/next-auth/src/client/__tests__/sign-out.test.js @@ -0,0 +1,124 @@ +import { useState } from "react" +import userEvent from "@testing-library/user-event" +import { render, screen, waitFor } from "@testing-library/react" +import { server, mockSignOutResponse } from "./helpers/mocks" +import { signOut } from "../../react" +import { rest } from "msw" +import { getBroadcastEvents } from "./helpers/utils" + +const { location } = window + +beforeAll(() => { + server.listen() + // Allows to mutate `window.location`... + delete window.location + window.location = { + reload: jest.fn(), + href: location.href, + } +}) + +beforeEach(() => { + // eslint-disable-next-line no-proto + jest.spyOn(window.localStorage.__proto__, "setItem") +}) + +afterEach(() => { + jest.clearAllMocks() + server.resetHandlers() +}) + +afterAll(() => { + window.location = location + server.close() +}) + +const callbackUrl = "https://redirects/to" + +test("by default it redirects to the current URL if the server did not provide one", async () => { + server.use( + rest.post("*/api/auth/signout", (req, res, ctx) => + res(ctx.status(200), ctx.json({ ...mockSignOutResponse, url: undefined })) + ) + ) + + render() + + userEvent.click(screen.getByRole("button")) + + await waitFor(() => { + expect(window.location.href).toBe(window.location.href) + }) +}) + +test("it redirects to the URL allowed by the server", async () => { + render() + + userEvent.click(screen.getByRole("button")) + + await waitFor(() => { + expect(window.location.href).toBe(mockSignOutResponse.url) + }) +}) + +test("if url contains a hash during redirection a page reload happens", async () => { + const mockUrlWithHash = "https://path/to/email/url#foo-bar-baz" + + server.use( + rest.post("*/api/auth/signout", (req, res, ctx) => { + return res( + ctx.status(200), + ctx.json({ + ...mockSignOutResponse, + url: mockUrlWithHash, + }) + ) + }) + ) + + render() + + userEvent.click(screen.getByRole("button")) + + await waitFor(() => { + expect(window.location.href).toBe(mockUrlWithHash) + }) +}) + +test("will broadcast the signout event to other tabs", async () => { + render() + + userEvent.click(screen.getByRole("button")) + + await waitFor(() => { + const broadcastCalls = getBroadcastEvents() + const [broadcastedEvent] = broadcastCalls + + expect(broadcastCalls).toHaveLength(1) + expect(broadcastedEvent.eventName).toBe("nextauth.message") + expect(broadcastedEvent.value).toStrictEqual({ + data: { + trigger: "signout", + }, + event: "session", + }) + }) +}) + +function SignOutFlow({ callbackUrl, redirect = true }) { + const [response, setResponse] = useState(null) + + async function handleSignOut() { + const result = await signOut({ callbackUrl, redirect }) + setResponse(result) + } + + return ( + <> +

+ {response ? JSON.stringify(response) : "no response"} +

+ + + ) +} diff --git a/packages/next-auth/src/client/__tests__/use-session-hook.test.js b/packages/next-auth/src/client/__tests__/use-session-hook.test.js new file mode 100644 index 00000000..86bd06b7 --- /dev/null +++ b/packages/next-auth/src/client/__tests__/use-session-hook.test.js @@ -0,0 +1,140 @@ +import { rest } from "msw" +import { renderHook } from "@testing-library/react-hooks" +import { render, waitFor } from "@testing-library/react" +import { SessionProvider, useSession, signOut } from "../../react" +import { server, mockSession } from "./helpers/mocks" + +const origConsoleError = console.error +const { location } = window + +let _href = window.location.href +beforeAll(() => { + // Prevent noise on the terminal... `next-auth` will log to `console.error` + // every time a request fails, which makes the tests output very noisy... + console.error = jest.fn() + + // Allows to mutate `window.location`... + delete window.location + window.location = {} + Object.defineProperty(window.location, "href", { + get: () => _href, + // whatwg-fetch or whatwg-url does not seem to work with relative URLs + set: (href) => { + _href = href.startsWith("/") ? `http://localhost${href}` : href + return _href + }, + }) + + server.listen() +}) + +afterEach(() => { + server.resetHandlers() + _href = "http://localhost/" + + // clear the internal session cache... + signOut({ redirect: false }) +}) + +afterAll(() => { + console.error = origConsoleError + window.location = location + server.close() +}) + +test("it won't allow to fetch the session in isolation without a session context", () => { + function App() { + useSession() + return null + } + + expect(() => render()).toThrow( + "[next-auth]: `useSession` must be wrapped in a " + ) +}) + +test("when fetching the session, there won't be `data` and `status` will be 'loading'", () => { + const { result } = renderHook(() => useSession(), { + wrapper: SessionProvider, + }) + + expect(result.current.data).toBe(undefined) + expect(result.current.status).toBe("loading") +}) + +test("when session is fetched, `data` will contain the session data and `status` will be 'authenticated'", async () => { + const { result } = renderHook(() => useSession(), { + wrapper: SessionProvider, + }) + + await waitFor(() => { + expect(result.current.data).toEqual(mockSession) + expect(result.current.status).toBe("authenticated") + }) +}) + +test("when it fails to fetch the session, `data` will be null and `status` will be 'unauthenticated'", async () => { + server.use( + rest.get(`http://localhost/api/auth/session`, (_, res, ctx) => + res(ctx.status(401), ctx.json({})) + ) + ) + + const { result } = renderHook(() => useSession(), { + wrapper: SessionProvider, + }) + + return waitFor(() => { + expect(result.current.data).toEqual(null) + expect(result.current.status).toBe("unauthenticated") + }) +}) + +test("it'll redirect to sign-in page if the session is required and the user is not authenticated", async () => { + server.use( + rest.get(`http://localhost/api/auth/session`, (req, res, ctx) => + res(ctx.status(401), ctx.json({})) + ) + ) + + const callbackUrl = window.location.href + const { result } = renderHook(() => useSession({ required: true }), { + wrapper: SessionProvider, + }) + + await waitFor(() => { + expect(result.current.data).toEqual(null) + expect(result.current.status).toBe("loading") + }) + + expect(window.location.href).toBe( + `http://localhost/api/auth/signin?${new URLSearchParams({ + error: "SessionRequired", + callbackUrl, + })}` + ) +}) + +test("will call custom redirect logic if supplied when the user could not authenticate", async () => { + server.use( + rest.get(`http://localhost/api/auth/session`, (_, res, ctx) => + res(ctx.status(401), ctx.json({})) + ) + ) + + const customRedirect = jest.fn() + + const { result } = renderHook( + () => useSession({ required: true, onUnauthenticated: customRedirect }), + { + wrapper: SessionProvider, + } + ) + + await waitFor(() => { + expect(result.current.data).toEqual(null) + expect(result.current.status).toBe("loading") + }) + + expect(customRedirect).toHaveBeenCalledTimes(1) +}) diff --git a/packages/next-auth/src/client/_utils.ts b/packages/next-auth/src/client/_utils.ts new file mode 100644 index 00000000..1f3819e0 --- /dev/null +++ b/packages/next-auth/src/client/_utils.ts @@ -0,0 +1,120 @@ +import type { IncomingMessage } from "http" +import type { LoggerInstance, Session } from ".." + +export interface AuthClientConfig { + baseUrl: string + basePath: string + baseUrlServer: string + basePathServer: string + /** Stores last session response */ + _session?: Session | null | undefined + /** Used for timestamp since last sycned (in seconds) */ + _lastSync: number + /** + * Stores the `SessionProvider`'s session update method to be able to + * trigger session updates from places like `signIn` or `signOut` + */ + _getSession: (...args: any[]) => any +} + +export interface CtxOrReq { + req?: Partial & { body?: any } + ctx?: { req: Partial & { body?: any } } +} + +/** + * If passed 'appContext' via getInitialProps() in _app.js + * then get the req object from ctx and use that for the + * req value to allow `fetchData` to + * work seemlessly in getInitialProps() on server side + * pages *and* in _app.js. + */ +export async function fetchData( + path: string, + __NEXTAUTH: AuthClientConfig, + logger: LoggerInstance, + { ctx, req = ctx?.req }: CtxOrReq = {} +): Promise { + const url = `${apiBaseUrl(__NEXTAUTH)}/${path}` + try { + const options: RequestInit = { + headers: { + "Content-Type": "application/json", + ...(req?.headers?.cookie ? { cookie: req.headers.cookie } : {}), + }, + } + + if (req?.body) { + options.body = JSON.stringify(req.body) + options.method = "POST" + } + + const res = await fetch(url, options) + const data = await res.json() + if (!res.ok) throw data + return Object.keys(data).length > 0 ? data : null // Return null if data empty + } catch (error) { + logger.error("CLIENT_FETCH_ERROR", { error: error as Error, url }) + return null + } +} + +export function apiBaseUrl(__NEXTAUTH: AuthClientConfig) { + if (typeof window === "undefined") { + // Return absolute path when called server side + return `${__NEXTAUTH.baseUrlServer}${__NEXTAUTH.basePathServer}` + } + // Return relative path when called client side + return __NEXTAUTH.basePath +} + +/** Returns the number of seconds elapsed since January 1, 1970 00:00:00 UTC. */ +export function now() { + return Math.floor(Date.now() / 1000) +} + +export interface BroadcastMessage { + event?: "session" + data?: { trigger?: "signout" | "getSession" } + clientId: string + timestamp: number +} + +/** + * Inspired by [Broadcast Channel API](https://developer.mozilla.org/en-US/docs/Web/API/Broadcast_Channel_API) + * Only not using it directly, because Safari does not support it. + * + * https://caniuse.com/?search=broadcastchannel + */ +export function BroadcastChannel(name = "nextauth.message") { + return { + /** Get notified by other tabs/windows. */ + receive(onReceive: (message: BroadcastMessage) => void) { + const handler = (event: StorageEvent) => { + if (event.key !== name) return + const message: BroadcastMessage = JSON.parse(event.newValue ?? "{}") + if (message?.event !== "session" || !message?.data) return + + onReceive(message) + } + window.addEventListener("storage", handler) + return () => window.removeEventListener("storage", handler) + }, + /** Notify other tabs/windows. */ + post(message: Record) { + if (typeof window === "undefined") return + try { + localStorage.setItem( + name, + JSON.stringify({ ...message, timestamp: now() }) + ) + } catch { + /** + * The localStorage API isn't always available. + * It won't work in private mode prior to Safari 11 for example. + * Notifications are simply dropped if an error is encountered. + */ + } + }, + } +} diff --git a/packages/next-auth/src/core/errors.ts b/packages/next-auth/src/core/errors.ts new file mode 100644 index 00000000..b2eaf0ba --- /dev/null +++ b/packages/next-auth/src/core/errors.ts @@ -0,0 +1,127 @@ +import type { EventCallbacks, LoggerInstance } from ".." + +/** + * 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 = "InvalidCallbackUrl" + code = "INVALID_CALLBACK_URL_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/next-auth/src/core/index.ts b/packages/next-auth/src/core/index.ts new file mode 100644 index 00000000..27ff069f --- /dev/null +++ b/packages/next-auth/src/core/index.ts @@ -0,0 +1,322 @@ +import logger, { setLogger } from "../utils/logger" +import { detectOrigin } from "../utils/detect-origin" +import * as routes from "./routes" +import renderPage from "./pages" +import { init } from "./init" +import { assertConfig } from "./lib/assert" +import { SessionStore } from "./lib/cookie" + +import type { AuthAction, AuthOptions } from "./types" +import type { Cookie } from "./lib/cookie" +import type { ErrorType } from "./pages/error" +import { parse as parseCookie } from "cookie" + +export interface RequestInternal { + /** @default "http://localhost:3000" */ + origin?: string + method?: string + cookies?: Partial> + headers?: Record + query?: Record + body?: Record + action: AuthAction + providerId?: string + error?: string +} + +export interface NextAuthHeader { + key: string + value: string +} + +export interface ResponseInternal< + Body extends string | Record | any[] = any +> { + status?: number + headers?: NextAuthHeader[] + body?: Body + redirect?: string + cookies?: Cookie[] +} + +export interface NextAuthHandlerParams { + req: Request | RequestInternal + options: AuthOptions +} + +async function getBody(req: Request): Promise | undefined> { + try { + return await req.json() + } catch {} +} + +// TODO: +async function toInternalRequest( + req: RequestInternal | Request +): Promise { + if (req instanceof Request) { + const url = new URL(req.url) + // TODO: handle custom paths? + const nextauth = url.pathname.split("/").slice(3) + const headers = Object.fromEntries(req.headers) + const query: Record = Object.fromEntries(url.searchParams) + query.nextauth = nextauth + + return { + action: nextauth[0] as AuthAction, + method: req.method, + headers, + body: await getBody(req), + cookies: parseCookie(req.headers.get("cookie") ?? ""), + providerId: nextauth[1], + error: url.searchParams.get("error") ?? nextauth[1], + origin: detectOrigin( + headers["x-forwarded-host"] ?? headers.host, + headers["x-forwarded-proto"] + ), + query, + } + } + + const { headers } = req + const host = headers?.["x-forwarded-host"] ?? headers?.host + req.origin = detectOrigin(host, headers?.["x-forwarded-proto"]) + + return req +} + +export async function AuthHandler< + Body extends string | Record | any[] +>(params: NextAuthHandlerParams): Promise> { + const { options: authOptions, req: incomingRequest } = params + + const req = await toInternalRequest(incomingRequest) + + setLogger(authOptions.logger, authOptions.debug) + + 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") { + const message = `There is a problem with the server configuration. Check the server logs for more information.` + return { + status: 500, + headers: [{ key: "Content-Type", value: "application/json" }], + body: { message } 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 = "GET" } = req + + const { options, cookies } = await init({ + authOptions, + action, + providerId, + origin: req.origin, + 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) + return { ...session, cookies } as any + } + case "csrf": + return { + headers: [{ key: "Content-Type", value: "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 {} + } + case "session": { + // Verified CSRF Token required for session updates + if (options.csrfTokenVerified) { + const session = await routes.session({ + options, + sessionStore, + newSession: req.body?.data, + isUpdate: true, + }) + if (session.cookies) cookies.push(...session.cookies) + return { ...session, cookies } as any + } + + // If CSRF token is invalid, return a 400 status code + // we should not redirect to a page as this is an API route + return { status: 400, body: {} as any, cookies } + } + default: + } + } + + return { + status: 400, + body: `Error: This action with HTTP ${method} is not supported by NextAuth.js` as any, + } +} diff --git a/packages/next-auth/src/core/init.ts b/packages/next-auth/src/core/init.ts new file mode 100644 index 00000000..15a97a29 --- /dev/null +++ b/packages/next-auth/src/core/init.ts @@ -0,0 +1,155 @@ +import { randomBytes, randomUUID } from "crypto" +import { AuthOptions } from ".." +import logger from "../utils/logger" +import { adapterErrorHandler, eventsErrorHandler } from "./errors" +import parseProviders from "./lib/providers" +import { createSecret } from "./lib/utils" +import * as cookie from "./lib/cookie" +import * as jwt from "../jwt" +import { defaultCallbacks } from "./lib/default-callbacks" +import { createCSRFToken } from "./lib/csrf-token" +import { createCallbackUrl } from "./lib/callback-url" +import { RequestInternal } from "." + +import type { InternalOptions } from "./types" +import parseUrl from "../utils/parse-url" + +interface InitParams { + origin?: string + 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, + origin, + cookies: reqCookies, + callbackUrl: reqCallbackUrl, + csrfToken: reqCsrfToken, + isPost, +}: InitParams): Promise<{ + options: InternalOptions + cookies: cookie.Cookie[] +}> { + const url = parseUrl(origin) + + const secret = createSecret({ authOptions, url }) + + 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 authOptions but we override them + // and are request-specific. + url, + action, + // @ts-expect-errors + provider, + cookies: { + ...cookie.defaultCookies( + authOptions.useSecureCookies ?? url.base.startsWith("https://") + ), + // Allow user cookie options to override any cookie settings above + ...authOptions.cookies, + }, + secret, + 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: () => { + // Use `randomUUID` if available. (Node 15.6+) + return randomUUID?.() ?? randomBytes(32).toString("hex") + }, + ...authOptions.session, + }, + // JWT options + jwt: { + secret, // Use application secret if no keys specified + 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, + } = 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/next-auth/src/core/lib/assert.ts b/packages/next-auth/src/core/lib/assert.ts new file mode 100644 index 00000000..9da40824 --- /dev/null +++ b/packages/next-auth/src/core/lib/assert.ts @@ -0,0 +1,149 @@ +import { + MissingAdapter, + MissingAPIRoute, + MissingAuthorize, + MissingSecret, + UnsupportedStrategy, + InvalidCallbackUrl, + MissingAdapterMethods, +} from "../errors" +import parseUrl from "../../utils/parse-url" +import { defaultCookies } from "./cookie" + +import type { RequestInternal } from ".." +import type { WarningCode } from "../../utils/logger" +import type { AuthOptions } from "../types" + +type ConfigError = + | MissingAPIRoute + | MissingSecret + | UnsupportedStrategy + | MissingAuthorize + | MissingAdapter + +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 `next-auth` 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 warnings: WarningCode[] = [] + + if (!warned) { + if (!req.origin) warnings.push("NEXTAUTH_URL") + + // TODO: Make this throw an error in next major. This will also get rid of `NODE_ENV` + if (!options.secret && process.env.NODE_ENV !== "production") + warnings.push("NO_SECRET") + + if (options.debug) warnings.push("DEBUG_ENABLED") + } + + if (!options.secret && process.env.NODE_ENV === "production") { + return new MissingSecret("Please define a `secret` in production.") + } + + // req.query isn't defined when asserting `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 + + const url = parseUrl(req.origin) + + if (callbackUrlParam && !isValidHttpUrl(callbackUrlParam, url.base)) { + return new InvalidCallbackUrl( + `Invalid callback URL. Received: ${callbackUrlParam}` + ) + } + + const { callbackUrl: defaultCallbackUrl } = defaultCookies( + options.useSecureCookies ?? url.base.startsWith("https://") + ) + const callbackUrlCookie = + req.cookies?.[options.cookies?.callbackUrl?.name ?? defaultCallbackUrl.name] + + if (callbackUrlCookie && !isValidHttpUrl(callbackUrlCookie, url.base)) { + return new InvalidCallbackUrl( + `Invalid callback URL. Received: ${callbackUrlCookie}` + ) + } + + let hasCredentials, hasEmail + let hasTwitterOAuth2 + + for (const provider of options.providers) { + if (provider.type === "credentials") hasCredentials = true + else if (provider.type === "email") hasEmail = true + else if (provider.id === "twitter" && provider.version === "2.0") + hasTwitterOAuth2 = 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) { + if (hasTwitterOAuth2) warnings.push("TWITTER_OAUTH_2_BETA") + warned = true + } + + return warnings +} diff --git a/packages/next-auth/src/core/lib/callback-handler.ts b/packages/next-auth/src/core/lib/callback-handler.ts new file mode 100644 index 00000000..f8c88a90 --- /dev/null +++ b/packages/next-auth/src/core/lib/callback-handler.ts @@ -0,0 +1,229 @@ +import { AccountNotLinkedError } from "../errors" +import { fromDate } from "./utils" + +import type { InternalOptions } from "../types" +import type { AdapterSession, AdapterUser } from "../../adapters" +import type { JWT } from "../../jwt" +import type { Account, User } from "../.." +import type { SessionToken } from "./cookie" +import { OAuthConfig } from "src/providers" + +/** + * 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"].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: await 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: await 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: await generateSessionToken(), + userId: user.id, + expires: fromDate(options.session.maxAge), + }) + + return { session, user, isNewUser: true } + } + } + + throw new Error("Unsupported account type") +} diff --git a/packages/next-auth/src/core/lib/callback-url.ts b/packages/next-auth/src/core/lib/callback-url.ts new file mode 100644 index 00000000..a89983d4 --- /dev/null +++ b/packages/next-auth/src/core/lib/callback-url.ts @@ -0,0 +1,42 @@ +import type { InternalOptions } from "../types" + +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/next-auth/src/core/lib/cookie.ts b/packages/next-auth/src/core/lib/cookie.ts new file mode 100644 index 00000000..c7ae681c --- /dev/null +++ b/packages/next-auth/src/core/lib/cookie.ts @@ -0,0 +1,237 @@ +import type { CookiesOptions } from "../.." +import type { CookieOption, LoggerInstance, SessionStrategy } from "../types" +import type { NextRequest } from "next/server" +import type { NextApiRequest } from "next" + +// 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: NextRequest["cookies"] | NextApiRequest["cookies"] + headers: NextRequest["headers"] | NextApiRequest["headers"] + }>, + 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/next-auth/src/core/lib/csrf-token.ts b/packages/next-auth/src/core/lib/csrf-token.ts new file mode 100644 index 00000000..cd614922 --- /dev/null +++ b/packages/next-auth/src/core/lib/csrf-token.ts @@ -0,0 +1,55 @@ +import { createHash, randomBytes } from "crypto" + +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 function createCSRFToken({ + options, + cookieValue, + isPost, + bodyValue, +}: CreateCSRFTokenParams) { + if (cookieValue) { + const [csrfToken, csrfTokenHash] = cookieValue.split("|") + const expectedCsrfTokenHash = createHash("sha256") + .update(`${csrfToken}${options.secret}`) + .digest("hex") + 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 = randomBytes(32).toString("hex") + const csrfTokenHash = createHash("sha256") + .update(`${csrfToken}${options.secret}`) + .digest("hex") + const cookie = `${csrfToken}|${csrfTokenHash}` + + return { cookie, csrfToken } +} diff --git a/packages/next-auth/src/core/lib/default-callbacks.ts b/packages/next-auth/src/core/lib/default-callbacks.ts new file mode 100644 index 00000000..d00298c7 --- /dev/null +++ b/packages/next-auth/src/core/lib/default-callbacks.ts @@ -0,0 +1,18 @@ +import { 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/next-auth/src/core/lib/email/getUserFromEmail.ts b/packages/next-auth/src/core/lib/email/getUserFromEmail.ts new file mode 100644 index 00000000..2119c28c --- /dev/null +++ b/packages/next-auth/src/core/lib/email/getUserFromEmail.ts @@ -0,0 +1,20 @@ +import type { AdapterUser } from "../../../adapters" +import type { InternalOptions } from "../../types" + +/** + * 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/next-auth/src/core/lib/email/signin.ts b/packages/next-auth/src/core/lib/email/signin.ts new file mode 100644 index 00000000..fa4ba151 --- /dev/null +++ b/packages/next-auth/src/core/lib/email/signin.ts @@ -0,0 +1,50 @@ +import { randomBytes } from "crypto" +import { hashToken } from "../utils" +import type { InternalOptions } from "../../types" + +/** + * 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?.()) ?? + randomBytes(32).toString("hex") + + 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}` + + await Promise.all([ + // Send to user + provider.sendVerificationRequest({ + identifier, + token, + expires, + url: _url, + provider, + theme, + }), + // Save in database + adapter.createVerificationToken({ + identifier, + token: hashToken(token, options), + expires, + }), + ]) + + return `${url}/verify-request?${new URLSearchParams({ + provider: provider.id, + type: provider.type, + })}` +} diff --git a/packages/next-auth/src/core/lib/oauth/authorization-url.ts b/packages/next-auth/src/core/lib/oauth/authorization-url.ts new file mode 100644 index 00000000..0fc022f9 --- /dev/null +++ b/packages/next-auth/src/core/lib/oauth/authorization-url.ts @@ -0,0 +1,63 @@ +import { openidClient } from "./client" +import { oAuth1Client, oAuth1TokenStore } from "./client-legacy" +import * as checks from "./checks" + +import type { AuthorizationParameters } from "openid-client" +import type { InternalOptions } from "../../types" +import type { RequestInternal } from "../.." +import type { Cookie } from "../cookie" + +/** + * + * Generates an authorization/request token URL. + * + * [OAuth 2](https://www.oauth.com/oauth2-servers/authorization/the-authorization-request/) | [OAuth 1](https://oauth.net/core/1.0a/#auth_step2) + */ +export default async function getAuthorizationUrl({ + options, + query, +}: { + options: InternalOptions<"oauth"> + query: RequestInternal["query"] +}) { + const { logger, provider } = options + let params: any = {} + + if (typeof provider.authorization === "string") { + const parsedUrl = new URL(provider.authorization) + const parsedParams = Object.fromEntries(parsedUrl.searchParams) + params = { ...params, ...parsedParams } + } else { + params = { ...params, ...provider.authorization?.params } + } + + params = { ...params, ...query } + + // Handle OAuth v1.x + if (provider.version?.startsWith("1.")) { + const client = oAuth1Client(options) + const tokens = (await client.getOAuthRequestToken(params)) as any + const url = `${provider.authorization?.url}?${new URLSearchParams({ + oauth_token: tokens.oauth_token, + oauth_token_secret: tokens.oauth_token_secret, + ...tokens.params, + })}` + oAuth1TokenStore.set(tokens.oauth_token, tokens.oauth_token_secret) + logger.debug("GET_AUTHORIZATION_URL", { url, provider }) + return { redirect: url } + } + + const client = await openidClient(options) + + const authorizationParams: AuthorizationParameters = params + const cookies: 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) + + logger.debug("GET_AUTHORIZATION_URL", { url, cookies, provider }) + return { redirect: url, cookies } +} diff --git a/packages/next-auth/src/core/lib/oauth/callback.ts b/packages/next-auth/src/core/lib/oauth/callback.ts new file mode 100644 index 00000000..3038150b --- /dev/null +++ b/packages/next-auth/src/core/lib/oauth/callback.ts @@ -0,0 +1,183 @@ +import { TokenSet } from "openid-client" +import { openidClient } from "./client" +import { oAuth1Client, oAuth1TokenStore } from "./client-legacy" +import * as _checks from "./checks" +import { OAuthCallbackError } from "../../errors" + +import type { CallbackParamsType } from "openid-client" +import type { LoggerInstance, Profile } from "../../.." +import type { OAuthChecks, OAuthConfig } from "../../../providers" +import type { InternalOptions } from "../../types" +import type { RequestInternal } from "../.." +import type { Cookie } from "../cookie" + +export default async function oAuthCallback(params: { + options: InternalOptions<"oauth"> + query: RequestInternal["query"] + body: RequestInternal["body"] + method: Required["method"] + cookies: RequestInternal["cookies"] +}) { + const { options, query, body, method, 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 + } + + if (provider.version?.startsWith("1.")) { + try { + const client = await oAuth1Client(options) + // Handle OAuth v1.x + const { oauth_token, oauth_verifier } = query ?? {} + const tokens = (await (client as any).getOAuthAccessToken( + oauth_token, + oAuth1TokenStore.get(oauth_token), + oauth_verifier + )) as TokenSet + let profile: Profile = await (client as any).get( + provider.profileUrl, + tokens.oauth_token, + tokens.oauth_token_secret + ) + + if (typeof profile === "string") { + profile = JSON.parse(profile) + } + + const newProfile = await getProfile({ profile, tokens, provider, logger }) + return { ...newProfile, cookies: [] } + } catch (error) { + logger.error("OAUTH_V1_GET_ACCESS_TOKEN_ERROR", error as Error) + throw error + } + } + + if (query?.oauth_token) oAuth1TokenStore.delete(query.oauth_token) + + try { + const client = await openidClient(options) + + let tokens: TokenSet + + const checks: OAuthChecks = {} + const resCookies: 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({ + url: `http://n?${new URLSearchParams(query)}`, + // TODO: Ask to allow object to be passed upstream: + // https://github.com/panva/node-openid-client/blob/3ae206dfc78c02134aa87a07f693052c637cab84/types/index.d.ts#L439 + // @ts-expect-error + body, + method, + }), + ...provider.token?.params, + } + + if (provider.token?.request) { + const response = await provider.token.request({ + provider, + params, + checks, + client, + }) + tokens = new TokenSet(response.tokens) + } else if (provider.idToken) { + tokens = await client.callback(provider.callbackUrl, params, checks) + } else { + tokens = await client.oauthCallback(provider.callbackUrl, params, checks) + } + + // REVIEW: How can scope be returned as an array? + if (Array.isArray(tokens.scope)) { + tokens.scope = tokens.scope.join(" ") + } + + let profile: Profile + if (provider.userinfo?.request) { + profile = await provider.userinfo.request({ + provider, + tokens, + client, + }) + } else if (provider.idToken) { + profile = tokens.claims() + } else { + profile = await client.userinfo(tokens, { + params: provider.userinfo?.params, + }) + } + + const profileResult = await getProfile({ + profile, + provider, + tokens, + logger, + }) + return { ...profileResult, cookies: resCookies } + } catch (error) { + throw new OAuthCallbackError(error as Error) + } +} + +export interface GetProfileParams { + profile: Profile + tokens: TokenSet + provider: OAuthConfig + 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/next-auth/src/core/lib/oauth/checks.ts b/packages/next-auth/src/core/lib/oauth/checks.ts new file mode 100644 index 00000000..f4970126 --- /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 { OAuthChecks } from "../../../providers" +import type { CookiesOptions, InternalOptions } from "../../types" +import type { Cookie } from "../cookie" + +/** 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/client-legacy.ts b/packages/next-auth/src/core/lib/oauth/client-legacy.ts new file mode 100644 index 00000000..6716fb27 --- /dev/null +++ b/packages/next-auth/src/core/lib/oauth/client-legacy.ts @@ -0,0 +1,73 @@ +// This is kept around for being backwards compatible with OAuth 1.0 providers. +// We have the intentions to provide only minor fixes for this in the future. + +import { OAuth } from "oauth" +import type { InternalOptions } from "../../types" + +/** + * Client supporting OAuth 1.x + */ +export function oAuth1Client(options: InternalOptions<"oauth">) { + const provider = options.provider + + const oauth1Client = new OAuth( + provider.requestTokenUrl as string, + provider.accessTokenUrl as string, + provider.clientId as string, + provider.clientSecret as string, + provider.version ?? "1.0", + provider.callbackUrl, + provider.encoding ?? "HMAC-SHA1" + ) + + // Promisify get() for OAuth1 + const originalGet = oauth1Client.get.bind(oauth1Client) + // @ts-expect-error + oauth1Client.get = async (...args) => { + return await new Promise((resolve, reject) => { + originalGet(...args, (error, result) => { + if (error) { + return reject(error) + } + resolve(result) + }) + }) + } + // Promisify getOAuth1AccessToken() for OAuth1 + const originalGetOAuth1AccessToken = + oauth1Client.getOAuthAccessToken.bind(oauth1Client) + // eslint-disable-next-line @typescript-eslint/no-misused-promises + oauth1Client.getOAuthAccessToken = async (...args: any[]) => { + return await new Promise((resolve, reject) => { + originalGetOAuth1AccessToken( + ...args, + (error: any, oauth_token: any, oauth_token_secret: any) => { + if (error) { + return reject(error) + } + resolve({ oauth_token, oauth_token_secret } as any) + } + ) + }) + } + + const originalGetOAuthRequestToken = + oauth1Client.getOAuthRequestToken.bind(oauth1Client) + // eslint-disable-next-line @typescript-eslint/no-misused-promises + oauth1Client.getOAuthRequestToken = async (params = {}) => { + return await new Promise((resolve, reject) => { + originalGetOAuthRequestToken( + params, + (error, oauth_token, oauth_token_secret, params) => { + if (error) { + return reject(error) + } + resolve({ oauth_token, oauth_token_secret, params } as any) + } + ) + }) + } + return oauth1Client +} + +export const oAuth1TokenStore = new Map() diff --git a/packages/next-auth/src/core/lib/oauth/client.ts b/packages/next-auth/src/core/lib/oauth/client.ts new file mode 100644 index 00000000..f00e1b90 --- /dev/null +++ b/packages/next-auth/src/core/lib/oauth/client.ts @@ -0,0 +1,48 @@ +import { Issuer, custom } from "openid-client" +import type { Client } from "openid-client" +import type { InternalOptions } from "../../types" + +/** + * NOTE: We can add auto discovery of the provider's endpoint + * that requires only one endpoint to be specified by the user. + * Check out `Issuer.discover` + * + * Client supporting OAuth 2.x and OIDC + */ +export async function openidClient( + options: InternalOptions<"oauth"> +): Promise { + const provider = options.provider + + if (provider.httpOptions) custom.setHttpOptionsDefaults(provider.httpOptions) + + let issuer: Issuer + if (provider.wellKnown) { + issuer = await Issuer.discover(provider.wellKnown) + } else { + issuer = new Issuer({ + issuer: provider.issuer as string, + authorization_endpoint: provider.authorization?.url, + token_endpoint: provider.token?.url, + userinfo_endpoint: provider.userinfo?.url, + jwks_uri: provider.jwks_endpoint, + }) + } + + const client = new issuer.Client( + { + client_id: provider.clientId as string, + client_secret: provider.clientSecret as string, + redirect_uris: [provider.callbackUrl], + ...provider.client, + }, + provider.jwks + ) + + // allow a 10 second skew + // See https://github.com/nextauthjs/next-auth/issues/3032 + // and https://github.com/nextauthjs/next-auth/issues/3067 + client[custom.clock_tolerance] = 10 + + return client +} diff --git a/packages/next-auth/src/core/lib/providers.ts b/packages/next-auth/src/core/lib/providers.ts new file mode 100644 index 00000000..57769905 --- /dev/null +++ b/packages/next-auth/src/core/lib/providers.ts @@ -0,0 +1,93 @@ +import { merge } from "../../utils/merge" + +import type { InternalProvider, OAuthConfigInternal } from "../types" +import type { OAuthConfig, Provider } from "../../providers" +import type { InternalUrl } from "../../utils/parse-url" + +/** + * Adds `signinUrl` and `callbackUrl` to each provider + * and deep merge user-defined options. + */ +export default function parseProviders(params: { + providers: Provider[] + url: InternalUrl + providerId?: string +}): { + providers: InternalProvider[] + provider?: InternalProvider +} { + const { url, providerId } = params + + const providers = params.providers.map( + ({ options: userOptions, ...rest }) => { + if (rest.type === "oauth") { + const normalizedOptions = normalizeOAuthOptions(rest) + const normalizedUserOptions = normalizeOAuthOptions(userOptions, true) + const id = normalizedUserOptions?.id ?? rest.id + return merge(normalizedOptions, { + ...normalizedUserOptions, + signinUrl: `${url}/signin/${id}`, + callbackUrl: `${url}/callback/${id}`, + }) + } + const id = (userOptions?.id as string) ?? rest.id + return merge(rest, { + ...userOptions, + signinUrl: `${url}/signin/${id}`, + callbackUrl: `${url}/callback/${id}`, + }) + } + ) + + return { + providers, + provider: providers.find(({ id }) => id === providerId), + } +} + +/** + * Transform OAuth options `authorization`, `token` and `profile` strings to `{ url: string; params: Record }` + */ +function normalizeOAuthOptions( + oauthOptions?: Partial> | Record, + isUserOptions = false +) { + if (!oauthOptions) return + + const normalized = Object.entries(oauthOptions).reduce< + OAuthConfigInternal> + >( + (acc, [key, value]) => { + if ( + ["authorization", "token", "userinfo"].includes(key) && + typeof value === "string" + ) { + const url = new URL(value) + acc[key] = { + url: `${url.origin}${url.pathname}`, + params: Object.fromEntries(url.searchParams ?? []), + } + } else { + acc[key] = value + } + + return acc + }, + // eslint-disable-next-line @typescript-eslint/prefer-reduce-type-parameter + {} as any + ) + + if (!isUserOptions && !normalized.version?.startsWith("1.")) { + // If provider has as an "openid-configuration" well-known endpoint + // or an "openid" scope request, it will also likely be able to receive an `id_token` + // Only do this if this function is not called with user options to avoid overriding in later stage. + normalized.idToken = Boolean( + normalized.idToken ?? + normalized.wellKnown?.includes("openid-configuration") ?? + normalized.authorization?.params?.scope?.includes("openid") + ) + + if (!normalized.checks) normalized.checks = ["state"] + } + return normalized +} diff --git a/packages/next-auth/src/core/lib/utils.ts b/packages/next-auth/src/core/lib/utils.ts new file mode 100644 index 00000000..c8296a76 --- /dev/null +++ b/packages/next-auth/src/core/lib/utils.ts @@ -0,0 +1,44 @@ +import { createHash } from "crypto" + +import type { AuthOptions } from "../.." +import type { InternalOptions } from "../types" +import type { InternalUrl } from "../../utils/parse-url" + +/** + * Takes a number in seconds and returns the date in the future. + * Optionally takes a second date parameter. In that case + * the date in the future will be calculated from that date instead of now. + */ +export function fromDate(time: number, date = Date.now()) { + return new Date(date + time * 1000) +} + +export function hashToken(token: string, options: InternalOptions<"email">) { + const { provider, secret } = options + return ( + createHash("sha256") + // Prefer provider specific secret, but use default secret if none specified + .update(`${token}${provider.secret ?? secret}`) + .digest("hex") + ) +} + +/** + * Secret used salt cookies and tokens (e.g. for CSRF protection). + * If no secret option is specified then it creates one on the fly + * based on options passed here. If options contains unique data, such as + * OAuth provider secrets and database credentials it should be sufficent. If no secret provided in production, we throw an error. */ +export function createSecret(params: { + authOptions: AuthOptions + url: InternalUrl +}) { + const { authOptions, url } = params + + return ( + authOptions.secret ?? + // TODO: Remove falling back to default secret, and error in dev if one isn't provided + createHash("sha256") + .update(JSON.stringify({ ...url, ...authOptions })) + .digest("hex") + ) +} diff --git a/packages/next-auth/src/core/pages/error.tsx b/packages/next-auth/src/core/pages/error.tsx new file mode 100644 index 00000000..ee78e458 --- /dev/null +++ b/packages/next-auth/src/core/pages/error.tsx @@ -0,0 +1,114 @@ +import { Theme } from "../.." +import { InternalUrl } from "../../utils/parse-url" + +/** + * 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?: InternalUrl + 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, + providers: params.providers, + 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/next-auth/src/core/pages/signin.tsx b/packages/next-auth/src/core/pages/signin.tsx new file mode 100644 index 00000000..2ad59a72 --- /dev/null +++ b/packages/next-auth/src/core/pages/signin.tsx @@ -0,0 +1,222 @@ +import type { InternalProvider, Theme } from "../types" +import type React 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 + // We only want to render providers + const providersToRender = providers.filter((provider) => { + if (provider.type === "oauth" || provider.type === "email") { + // Always render oauth and email type providers + return true + } else if (provider.type === "credentials" && provider.credentials) { + // Only render credentials type provider if credentials are defined + return true + } + // Don't render other provider types + return false + }) + + if (typeof document !== "undefined" && theme.buttonText) { + document.documentElement.style.setProperty( + "--button-text-color", + theme.buttonText + ) + } + + 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) + + const logos = "https://authjs.dev/img/providers" + return ( +
+ {theme.brandColor && ( +