From 748d576a5adca2f2ce90cde406bf251c74807878 Mon Sep 17 00:00:00 2001 From: Manish Kumar Date: Wed, 9 Jun 2021 20:31:00 +0530 Subject: [PATCH 1/7] docs(adapter): align DynamoDB docs with source code (#2125) * Updated DynamoDB Adaptor documentation * Update dynamodb.md * Update dynamodb.md * Update dynamodb.md --- www/docs/adapters/dynamodb.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/www/docs/adapters/dynamodb.md b/www/docs/adapters/dynamodb.md index d4292cda01..891a8cae5f 100644 --- a/www/docs/adapters/dynamodb.md +++ b/www/docs/adapters/dynamodb.md @@ -21,7 +21,8 @@ npm install next-auth @next-auth/dynamodb-adapter@canary 2. Add this adapter to your `pages/api/auth/[...nextauth].js` next-auth configuration object. -You need to pass `aws-sdk` to the adapter in addition to the table name. +You need to pass `DocumentClient` instance from `aws-sdk` to the adapter. +The default table name is `next-auth`, but you can customise that by passing `{ tableName: 'your-table-name' }` as the second parameter in the adapter. ```javascript title="pages/api/auth/[...nextauth].js" import AWS from "aws-sdk"; @@ -48,10 +49,9 @@ export default NextAuth({ }), // ...add more providers here ], - adapter: DynamoDBAdapter({ - AWS, - tableName: "next-auth-test", - }), + adapter: DynamoDBAdapter( + new AWS.DynamoDB.DocumentClient() + ), ... }); ``` From 8ff7dbb18f07ecf3fc0075725014279c45402208 Mon Sep 17 00:00:00 2001 From: Apoorv Taneja Date: Wed, 9 Jun 2021 20:34:41 +0530 Subject: [PATCH 2/7] docs(tutorial): Adding a YouTube link for NextAuth.js introduction (#2047) Co-authored-by: Lluis Agusti --- www/docs/tutorials.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/www/docs/tutorials.md b/www/docs/tutorials.md index b72f437b39..0efa486e16 100644 --- a/www/docs/tutorials.md +++ b/www/docs/tutorials.md @@ -83,6 +83,10 @@ This example shows how to implement a fullstack app in TypeScript with Next.js u This `dev.to` tutorial walks one through adding NextAuth.js to an existing project. Including setting up the OAuth client id and secret, adding the API routes for authentication, protecting pages and API routes behind that authentication, etc. +### [Introduction to NextAuth.js](https://www.youtube.com/watch?v=npZsJxWntJM) + +This is an introductory video to NextAuth.js for beginners. In this video, it is explained how to set up authentication in a few easy steps and add different configurations to make it more robust and secure. + ### [Adding Sign in With Apple Next JS](https://thesiddd.com/blog/apple-auth) This tutorial walks step by step on how to get Sign In with Apple working (both locally and on a deployed website) using NextAuth.js. From 2657e72e8116129d4c5ac7a0773c6a4e95f010c4 Mon Sep 17 00:00:00 2001 From: Nicholas Chiang Date: Wed, 9 Jun 2021 13:17:45 -0700 Subject: [PATCH 3/7] docs(callbacks): don't use `signIn` for redirects (#2150) Specifies that you shouldn't use the `signIn` callback for arbitrary redirects. Instead, use the `callbackUrl` option or the redirect callback. --- www/docs/configuration/callbacks.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/www/docs/configuration/callbacks.md b/www/docs/configuration/callbacks.md index 98b7142a24..81e702b693 100644 --- a/www/docs/configuration/callbacks.md +++ b/www/docs/configuration/callbacks.md @@ -78,6 +78,11 @@ When using NextAuth.js with a database, the User object will be either a user ob When using NextAuth.js without a database, the user object it will always be a prototype user object, with information extracted from the profile. ::: +:::note +Redirects returned by this callback cancel the authentication flow. Only redirect to error pages that, for example, tell the user why they're not allowed to sign in. + +To redirect to a page after a successful sign in, please use [the `callbackUrl` option](/getting-started/client#specifying-a-callbackurl) or [the redirect callback](/configuration/callbacks#redirect-callback). +::: ## Redirect callback From 929c64465378a19c47ac8383c2fb99ff89d5496e Mon Sep 17 00:00:00 2001 From: Nicholas Chiang Date: Wed, 9 Jun 2021 13:19:26 -0700 Subject: [PATCH 4/7] docs(client): fix callback anchor links (#2151) --- www/docs/getting-started/client.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/www/docs/getting-started/client.md b/www/docs/getting-started/client.md index 5b8b68b47c..73c8bae8df 100644 --- a/www/docs/getting-started/client.md +++ b/www/docs/getting-started/client.md @@ -22,7 +22,7 @@ The NextAuth.js client library makes it easy to interact with sessions from Reac :::tip The session data returned to the client does not contain sensitive information such as the Session Token or OAuth tokens. It contains a minimal payload that includes enough data needed to display information on a page about the user who is signed in for presentation purposes (e.g name, email, image). -You can use the [session callback](/configuration/callbacks#session) to customize the session object returned to the client if you need to return additional data in the session object. +You can use the [session callback](/configuration/callbacks#session-callback) to customize the session object returned to the client if you need to return additional data in the session object. ::: --- @@ -208,7 +208,7 @@ e.g. * `signIn('google', { callbackUrl: 'http://localhost:3000/foo' })` * `signIn('email', { email, callbackUrl: 'http://localhost:3000/foo' })` -The URL must be considered valid by the [redirect callback handler](/configuration/callbacks#redirect). By default it requires the URL to be an absolute URL at the same hostname, or else it will redirect to the homepage. You can define your own redirect callback to allow other URLs, including supporting relative URLs. +The URL must be considered valid by the [redirect callback handler](/configuration/callbacks#redirect-callback). By default it requires the URL to be an absolute URL at the same hostname, or else it will redirect to the homepage. You can define your own [redirect callback](/configuration/callbacks#redirect-callback) to allow other URLs, including supporting relative URLs. #### Using the redirect: false option @@ -294,7 +294,7 @@ As with the `signIn()` function, you can specify a `callbackUrl` parameter by pa e.g. `signOut({ callbackUrl: 'http://localhost:3000/foo' })` -The URL must be considered valid by the [redirect callback handler](/configuration/callbacks#redirect). By default this means it must be an absolute URL at the same hostname (or else it will default to the homepage); you can define your own custom redirect callback to allow other URLs, including supporting relative URLs. +The URL must be considered valid by the [redirect callback handler](/configuration/callbacks#redirect-callback). By default this means it must be an absolute URL at the same hostname (or else it will default to the homepage); you can define your own custom [redirect callback](/configuration/callbacks#redirect-callback) to allow other URLs, including supporting relative URLs. #### Using the redirect: false option From 5aa2b61b88097429a4caae6ea4f340df7325b0d4 Mon Sep 17 00:00:00 2001 From: Christopher Betz Date: Wed, 9 Jun 2021 16:46:12 -0400 Subject: [PATCH 5/7] feat(provider): add Coinbase provider (#2153) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Balázs Orbán --- src/providers/coinbase.js | 24 +++++++++++++++++++++ types/providers.d.ts | 1 + www/docs/providers/coinbase.md | 38 ++++++++++++++++++++++++++++++++++ 3 files changed, 63 insertions(+) create mode 100644 src/providers/coinbase.js create mode 100644 www/docs/providers/coinbase.md diff --git a/src/providers/coinbase.js b/src/providers/coinbase.js new file mode 100644 index 0000000000..5fb92aeb5e --- /dev/null +++ b/src/providers/coinbase.js @@ -0,0 +1,24 @@ +export default function Coinbase(options) { + return { + id: "coinbase", + name: "Coinbase", + type: "oauth", + version: "2.0", + scope: "wallet:user:email wallet:user:read", + params: { grant_type: "authorization_code" }, + accessTokenUrl: "https://api.coinbase.com/oauth/token", + requestTokenUrl: "https://api.coinbase.com/oauth/token", + authorizationUrl: + "https://www.coinbase.com/oauth/authorize?response_type=code", + profileUrl: "https://api.coinbase.com/v2/user", + profile(profile) { + return { + id: profile.data.id, + name: profile.data.name, + email: profile.data.email, + image: profile.data.avatar_url, + } + }, + ...options, + } +} diff --git a/types/providers.d.ts b/types/providers.d.ts index e1af72e845..df3e0c6972 100644 --- a/types/providers.d.ts +++ b/types/providers.d.ts @@ -63,6 +63,7 @@ export type OAuthProviderType = | "Box" | "Bungie" | "Cognito" + | "Coinbase" | "Discord" | "Dropbox" | "EVEOnline" diff --git a/www/docs/providers/coinbase.md b/www/docs/providers/coinbase.md new file mode 100644 index 0000000000..18873cf0a1 --- /dev/null +++ b/www/docs/providers/coinbase.md @@ -0,0 +1,38 @@ +--- +id: coinbase +title: Coinbase +--- + +## Documentation + +https://developers.coinbase.com/api/v2 + +## Configuration + +https://www.coinbase.com/settings/api + +## Options + +The **Coinbase Provider** comes with a set of default options: + +- [Coinbase Provider options](https://github.com/nextauthjs/next-auth/blob/main/src/providers/coinbase.js) + +You can override any of the options to suit your own use case. + +## Example + +```js +import Providers from `next-auth/providers` +... +providers: [ + Providers.Coinbase({ + clientId: process.env.COINBASE_CLIENT_ID, + clientSecret: process.env.COINBASE_CLIENT_SECRET + }) +] +... +``` + +:::tip +This Provider template has a 2 hour access token to it. A refresh token is also returned. +::: From 29862ac887d76fa936bd0a8236acdce81133529f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bal=C3=A1zs=20Orb=C3=A1n?= Date: Thu, 10 Jun 2021 00:24:06 +0200 Subject: [PATCH 6/7] fix(build): do not run husky on postinstall (#2158) --- package.json | 1 - 1 file changed, 1 deletion(-) diff --git a/package.json b/package.json index 25ff5f3163..c2120601f0 100644 --- a/package.json +++ b/package.json @@ -29,7 +29,6 @@ "./errors": "./dist/lib/errors.js" }, "scripts": { - "postinstall": "npx husky install", "build": "npm run build:js && npm run build:css", "build:js": "node ./config/build.js && babel --config-file ./config/babel.config.js src --out-dir dist", "build:css": "postcss --config config/postcss.config.js src/**/*.css --base src --dir dist && node config/wrap-css.js", From 832d51f10e8cca432b4b52278202d44ce657b415 Mon Sep 17 00:00:00 2001 From: Lluis Agusti Date: Thu, 10 Jun 2021 11:42:58 +0200 Subject: [PATCH 7/7] test(client): add more tests (#2135) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Contains the following squashed commits: * test(client): verify CSRF Token fetch * test(client): verify `getProviders` logic * test(client): verify `useSession` happy path * test(coverage): initial coverage setup (trial) * chore(test): fix coverage reporting * chore(test): define report directory for codecov Co-authored-by: Balázs Orbán --- .github/workflows/release.yml | 7 +- .gitignore | 5 +- config/jest.config.js | 7 +- src/client/__tests__/client-provider.test.js | 64 +++++++++++ src/client/__tests__/csrf.test.js | 105 +++++++++++++++++++ src/client/__tests__/{ => helpers}/mocks.js | 3 + src/client/__tests__/{ => helpers}/utils.js | 0 src/client/__tests__/providers.test.js | 85 +++++++++++++++ src/client/__tests__/session.test.js | 17 +-- src/client/__tests__/sign-in.test.js | 6 +- src/client/__tests__/sign-out.test.js | 10 +- 11 files changed, 289 insertions(+), 20 deletions(-) create mode 100644 src/client/__tests__/client-provider.test.js create mode 100644 src/client/__tests__/csrf.test.js rename src/client/__tests__/{ => helpers}/mocks.js (98%) rename src/client/__tests__/{ => helpers}/utils.js (100%) create mode 100644 src/client/__tests__/providers.test.js diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 32af9a37f1..4aa3793e10 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -21,7 +21,12 @@ jobs: - name: Dependencies uses: bahmutov/npm-install@v1 - name: Run tests - run: npm test + run: npm test -- --coverage --verbose + - name: Coverage + uses: codecov/codecov-action@v1 + with: + directory: ./coverage + fail_ci_if_error: false - name: Build run: npm run build release: diff --git a/.gitignore b/.gitignore index e583affafe..c7b61b35ae 100644 --- a/.gitignore +++ b/.gitignore @@ -58,4 +58,7 @@ app/yarn.lock /_work # Prisma migrations -/prisma/migrations \ No newline at end of file +/prisma/migrations + +# Tests +/coverage \ No newline at end of file diff --git a/config/jest.config.js b/config/jest.config.js index c284229eb9..e4c5bc9974 100644 --- a/config/jest.config.js +++ b/config/jest.config.js @@ -1,8 +1,11 @@ +/** @type {import('@jest/types').Config.InitialOptions} */ module.exports = { transform: { "\\.js$": ["babel-jest", { configFile: "./config/babel.config.js" }], }, - roots: ["../src"], - setupFilesAfterEnv: ["./jest-setup.js"], + rootDir: "../src", + setupFilesAfterEnv: ["../config/jest-setup.js"], + collectCoverageFrom: ["!client/__tests__/**"], testMatch: ["**/*.test.js"], + coverageDirectory: "../coverage", } diff --git a/src/client/__tests__/client-provider.test.js b/src/client/__tests__/client-provider.test.js new file mode 100644 index 0000000000..f11e38bfa0 --- /dev/null +++ b/src/client/__tests__/client-provider.test.js @@ -0,0 +1,64 @@ +import { useState } from "react" +import { rest } from "msw" +import { render, screen, waitFor } from "@testing-library/react" +import { server, mockSession } from "./helpers/mocks" +import { Provider, useSession } from ".." +import userEvent from "@testing-library/user-event" + +beforeAll(() => { + server.listen() +}) + +afterEach(() => { + jest.clearAllMocks() + server.resetHandlers() +}) + +afterAll(() => { + server.close() +}) + +test("fetches the session once and re-uses it for different consumers", async () => { + const sessionRouteCall = jest.fn() + + server.use( + rest.get("/api/auth/session", (req, res, ctx) => { + sessionRouteCall() + res(ctx.status(200), ctx.json(mockSession)) + }) + ) + + render() + + await waitFor(() => { + expect(sessionRouteCall).toHaveBeenCalledTimes(1) + + const session1 = screen.getByTestId("session-consumer-1").textContent + const session2 = screen.getByTestId("session-consumer-2").textContent + + expect(session1).toEqual(session2) + }) +}) + +function ProviderFlow({ options = {} }) { + return ( + <> + + + + + + ) +} + +function SessionConsumer({ testId = 1 }) { + const [session, loading] = useSession() + + if (loading) return loading + + return ( +
+ {JSON.stringify(session)} +
+ ) +} diff --git a/src/client/__tests__/csrf.test.js b/src/client/__tests__/csrf.test.js new file mode 100644 index 0000000000..5f19a578eb --- /dev/null +++ b/src/client/__tests__/csrf.test.js @@ -0,0 +1,105 @@ +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 "../../lib/logger" +import { getCsrfToken } from ".." +import { rest } from "msw" + +jest.mock("../../lib/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", + "csrf", + 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/src/client/__tests__/mocks.js b/src/client/__tests__/helpers/mocks.js similarity index 98% rename from src/client/__tests__/mocks.js rename to src/client/__tests__/helpers/mocks.js index 9b2336baac..d4de299192 100644 --- a/src/client/__tests__/mocks.js +++ b/src/client/__tests__/helpers/mocks.js @@ -3,6 +3,7 @@ import { rest } from "msw" import { randomBytes } from "crypto" export const mockSession = { + ok: true, user: { image: null, name: "John", @@ -12,6 +13,7 @@ export const mockSession = { } export const mockProviders = { + ok: true, github: { id: "github", name: "Github", @@ -34,6 +36,7 @@ export const mockProviders = { } export const mockCSRFToken = { + ok: true, csrfToken: randomBytes(32).toString("hex"), } diff --git a/src/client/__tests__/utils.js b/src/client/__tests__/helpers/utils.js similarity index 100% rename from src/client/__tests__/utils.js rename to src/client/__tests__/helpers/utils.js diff --git a/src/client/__tests__/providers.test.js b/src/client/__tests__/providers.test.js new file mode 100644 index 0000000000..904272bd5a --- /dev/null +++ b/src/client/__tests__/providers.test.js @@ -0,0 +1,85 @@ +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 ".." +import logger from "../../lib/logger" +import { rest } from "msw" + +jest.mock("../../lib/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", + "providers", + 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/src/client/__tests__/session.test.js b/src/client/__tests__/session.test.js index e701a59aee..efe6545284 100644 --- a/src/client/__tests__/session.test.js +++ b/src/client/__tests__/session.test.js @@ -1,10 +1,10 @@ import { render, screen, waitFor } from "@testing-library/react" import { rest } from "msw" -import { server, mockSession } from "./mocks" +import { server, mockSession } from "./helpers/mocks" import logger from "../../lib/logger" import { useState, useEffect } from "react" import { getSession } from ".." -import { getBroadcastEvents } from "./utils" +import { getBroadcastEvents } from "./helpers/utils" jest.mock("../../lib/logger", () => ({ __esModule: true, @@ -27,10 +27,12 @@ beforeEach(() => { afterEach(() => { server.resetHandlers() - jest.restoreAllMocks() + jest.clearAllMocks() }) -afterAll(() => server.close()) +afterAll(() => { + server.close() +}) test("if it can fetch the session, it should store it in `localStorage`", async () => { render() @@ -81,7 +83,7 @@ function SessionFlow() { useEffect(() => { async function fetchUserSession() { try { - const result = await getSession({}) + const result = await getSession() setSession(result) } catch (e) { console.error(e) @@ -90,8 +92,7 @@ function SessionFlow() { fetchUserSession() }, []) - if (session) { - return
{JSON.stringify(session, null, 2)}
- } + if (session) return
{JSON.stringify(session, null, 2)}
+ return

No session

} diff --git a/src/client/__tests__/sign-in.test.js b/src/client/__tests__/sign-in.test.js index d8cd12a737..f0aa0b620c 100644 --- a/src/client/__tests__/sign-in.test.js +++ b/src/client/__tests__/sign-in.test.js @@ -7,7 +7,7 @@ import { mockCredentialsResponse, mockEmailResponse, mockGithubResponse, -} from "./mocks" +} from "./helpers/mocks" import { signIn } from ".." import { rest } from "msw" @@ -36,7 +36,7 @@ beforeAll(() => { }) beforeEach(() => { - jest.resetAllMocks() + jest.clearAllMocks() server.resetHandlers() }) @@ -284,7 +284,7 @@ function SignInFlow({

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

- + ) } diff --git a/src/client/__tests__/sign-out.test.js b/src/client/__tests__/sign-out.test.js index 1b8261ff7e..3168f91c09 100644 --- a/src/client/__tests__/sign-out.test.js +++ b/src/client/__tests__/sign-out.test.js @@ -1,10 +1,10 @@ import { useState } from "react" import userEvent from "@testing-library/user-event" import { render, screen, waitFor } from "@testing-library/react" -import { server, mockSignOutResponse } from "./mocks" +import { server, mockSignOutResponse } from "./helpers/mocks" import { signOut } from ".." import { rest } from "msw" -import { getBroadcastEvents } from "./utils" +import { getBroadcastEvents } from "./helpers/utils" const { location } = window @@ -24,7 +24,7 @@ beforeEach(() => { }) afterEach(() => { - jest.resetAllMocks() + jest.clearAllMocks() server.resetHandlers() }) @@ -113,7 +113,7 @@ test("will broadcast the signout event to other tabs", async () => { function SignOutFlow({ callbackUrl, redirect = true }) { const [response, setResponse] = useState(null) - async function setSignOutRes() { + async function handleSignOut() { const result = await signOut({ callbackUrl, redirect }) setResponse(result) } @@ -123,7 +123,7 @@ function SignOutFlow({ callbackUrl, redirect = true }) {

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

- + ) }