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 @@ +
Authentication for Next.js
++ Open Source. Full Stack. Own Your Data. +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
|
+
+ + Vercel + 🥉 Bronze Financial Sponsor ☁️ Infrastructure Support + |
+
+
+ + Prisma + 🥉 Bronze Financial Sponsor + |
+
+
+ + Clerk + 🥉 Bronze Financial Sponsor + |
+
+
+ + Lowdefy + 🥉 Bronze Financial Sponsor + |
+
+
+ + WorkOS + 🥉 Bronze Financial Sponsor + |
+
+
+ + Checkly + ☁️ Infrastructure Support + |
+
+
+
+ + superblog + ☁️ Infrastructure Support + |
+
+ {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(+ {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({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(+ {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(+ {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(+ + {url?.host} + +
+ ), + }, + configuration: { + status: 500, + heading: "Server error", + message: ( +There is a problem with the server configuration.
+Check the server logs for more information.
+You do not have permission to sign in.
++ + Sign in + +
+The sign in link is no longer valid.
+It may have been used already or it may have expired.
++ + Sign in + +
+ ), + }, + } + + const { status, heading, message, signin } = + errors[error.toLowerCase()] ?? errors.default + + return { + status, + html: ( +{error}
+Are you sure you want to sign out?
+ +A sign in link has been sent to your email address.
++ + {url.host} + +
+ {
+ /**
+ * Use this callback to control if a user is allowed to sign in.
+ * Returning true will continue the sign-in flow.
+ * Throwing an error or returning a string will stop the flow, and redirect the user.
+ *
+ * [Documentation](https://next-auth.js.org/configuration/callbacks#sign-in-callback)
+ */
+ signIn: (params: {
+ user: User | AdapterUser
+ account: A | null
+ /**
+ * If OAuth provider is used, it contains the full
+ * OAuth profile returned by your provider.
+ */
+ profile?: P
+ /**
+ * If Email provider is used, on the first call, it contains a
+ * `verificationRequest: true` property to indicate it is being triggered in the verification request flow.
+ * When the callback is invoked after a user has clicked on a sign in link,
+ * this property will not be present. You can check for the `verificationRequest` property
+ * to avoid sending emails to addresses or domains on a blocklist or to only explicitly generate them
+ * for email address in an allow list.
+ */
+ email?: {
+ verificationRequest?: boolean
+ }
+ /** If Credentials provider is used, it contains the user credentials */
+ credentials?: Record
+ extends Omit (
+ options: OAuthUserConfig
+): OAuthConfig {
+ return {
+ id: "42-school",
+ name: "42 School",
+ type: "oauth",
+ authorization: {
+ url: "https://api.intra.42.fr/oauth/authorize",
+ params: { scope: "public" },
+ },
+ token: "https://api.intra.42.fr/oauth/token",
+ userinfo: "https://api.intra.42.fr/v2/me",
+ profile(profile) {
+ return {
+ id: profile.id.toString(),
+ name: profile.usual_full_name,
+ email: profile.email,
+ image: profile.image_url,
+ }
+ },
+ options,
+ }
+}
diff --git a/packages/next-auth/src/providers/apple.ts b/packages/next-auth/src/providers/apple.ts
new file mode 100644
index 00000000..5ab16885
--- /dev/null
+++ b/packages/next-auth/src/providers/apple.ts
@@ -0,0 +1,130 @@
+import { OAuthConfig, OAuthUserConfig } from "."
+
+/**
+ * See more at:
+ * [Retrieve the User's Information from Apple ID Servers
+](https://developer.apple.com/documentation/sign_in_with_apple/sign_in_with_apple_rest_api/authenticating_users_with_sign_in_with_apple#3383773)
+ */
+export interface AppleProfile extends Record (
+ options: Omit {
+ return {
+ id: "apple",
+ name: "Apple",
+ type: "oauth",
+ wellKnown: "https://appleid.apple.com/.well-known/openid-configuration",
+ authorization: {
+ params: { scope: "name email", response_mode: "form_post" },
+ },
+ idToken: true,
+ profile(profile) {
+ return {
+ id: profile.sub,
+ name: profile.name,
+ email: profile.email,
+ image: null,
+ }
+ },
+ checks: ["pkce"],
+ style: {
+ logo: "/apple.svg",
+ logoDark: "/apple-dark.svg",
+ bg: "#fff",
+ text: "#000",
+ bgDark: "#000",
+ textDark: "#fff",
+ },
+ options,
+ }
+}
diff --git a/packages/next-auth/src/providers/atlassian.ts b/packages/next-auth/src/providers/atlassian.ts
new file mode 100644
index 00000000..1b18036f
--- /dev/null
+++ b/packages/next-auth/src/providers/atlassian.ts
@@ -0,0 +1,44 @@
+import type { OAuthConfig, OAuthUserConfig } from "."
+
+interface AtlassianProfile extends Record (
+ options: OAuthUserConfig
+): OAuthConfig {
+ return {
+ id: "atlassian",
+ name: "Atlassian",
+ type: "oauth",
+ authorization: {
+ url: "https://auth.atlassian.com/authorize",
+ params: {
+ audience: "api.atlassian.com",
+ prompt: "consent",
+ },
+ },
+ token: "https://auth.atlassian.com/oauth/token",
+ userinfo: "https://api.atlassian.com/me",
+ profile(profile) {
+ return {
+ id: profile.account_id,
+ name: profile.name,
+ email: profile.email,
+ image: profile.picture,
+ }
+ },
+ style: {
+ logo: "/atlassian.svg",
+ logoDark: "/atlassian-dark.svg",
+ bg: "#0052cc",
+ text: "#fff",
+ bgDark: "#fff",
+ textDark: "#0052cc",
+ },
+ options,
+ }
+}
diff --git a/packages/next-auth/src/providers/auth0.ts b/packages/next-auth/src/providers/auth0.ts
new file mode 100644
index 00000000..8ad5ead0
--- /dev/null
+++ b/packages/next-auth/src/providers/auth0.ts
@@ -0,0 +1,39 @@
+import type { OAuthConfig, OAuthUserConfig } from "."
+
+export interface Auth0Profile extends Record (
+ options: OAuthUserConfig
+): OAuthConfig {
+ return {
+ id: "auth0",
+ name: "Auth0",
+ wellKnown: `${options.issuer}/.well-known/openid-configuration`,
+ type: "oauth",
+ authorization: { params: { scope: "openid email profile" } },
+ checks: ["pkce", "state"],
+ idToken: true,
+ profile(profile) {
+ return {
+ id: profile.sub,
+ name: profile.nickname,
+ email: profile.email,
+ image: profile.picture,
+ }
+ },
+ style: {
+ logo: "/auth0.svg",
+ logoDark: "/auth0-dark.svg",
+ bg: "#fff",
+ text: "#EB5424",
+ bgDark: "#EB5424",
+ textDark: "#fff",
+ },
+ options,
+ }
+}
diff --git a/packages/next-auth/src/providers/authentik.ts b/packages/next-auth/src/providers/authentik.ts
new file mode 100644
index 00000000..cacab1bb
--- /dev/null
+++ b/packages/next-auth/src/providers/authentik.ts
@@ -0,0 +1,44 @@
+import type { OAuthConfig, OAuthUserConfig } from "."
+
+export interface AuthentikProfile extends Record (
+ options: OAuthUserConfig
+): OAuthConfig {
+ return {
+ id: "authentik",
+ name: "Authentik",
+ wellKnown: `${options.issuer}/.well-known/openid-configuration`,
+ type: "oauth",
+ authorization: { params: { scope: "openid email profile" } },
+ checks: ["pkce", "state"],
+ profile(profile) {
+ return {
+ id: profile.sub,
+ name: profile.name ?? profile.preferred_username,
+ email: profile.email,
+ image: profile.picture,
+ }
+ },
+ options,
+ }
+}
diff --git a/packages/next-auth/src/providers/azure-ad-b2c.ts b/packages/next-auth/src/providers/azure-ad-b2c.ts
new file mode 100644
index 00000000..fd32be87
--- /dev/null
+++ b/packages/next-auth/src/providers/azure-ad-b2c.ts
@@ -0,0 +1,55 @@
+import type { OAuthConfig, OAuthUserConfig } from "."
+
+export interface AzureB2CProfile extends Record (
+ options: OAuthUserConfig & {
+ primaryUserFlow?: string
+ tenantId?: string
+ }
+): OAuthConfig {
+ const { tenantId, primaryUserFlow } = options
+ const issuer =
+ options.issuer ??
+ `https://${tenantId}.b2clogin.com/${tenantId}.onmicrosoft.com/${primaryUserFlow}/v2.0`
+ return {
+ id: "azure-ad-b2c",
+ name: "Azure Active Directory B2C",
+ type: "oauth",
+ wellKnown: `${issuer}/.well-known/openid-configuration`,
+ idToken: true,
+ profile(profile) {
+ return {
+ id: profile.sub,
+ name: profile.name,
+ email: profile.emails[0],
+ // TODO: Find out how to retrieve the profile picture
+ image: null,
+ }
+ },
+ style: {
+ logo: "/azure.svg",
+ logoDark: "/azure-dark.svg",
+ bg: "#fff",
+ text: "#0072c6",
+ bgDark: "#0072c6",
+ textDark: "#fff",
+ },
+ options,
+ }
+}
diff --git a/packages/next-auth/src/providers/azure-ad.ts b/packages/next-auth/src/providers/azure-ad.ts
new file mode 100644
index 00000000..f12b07c9
--- /dev/null
+++ b/packages/next-auth/src/providers/azure-ad.ts
@@ -0,0 +1,69 @@
+import type { OAuthConfig, OAuthUserConfig } from "."
+
+export interface AzureADProfile extends Record (
+ options: OAuthUserConfig & {
+ /**
+ * https://docs.microsoft.com/en-us/graph/api/profilephoto-get?view=graph-rest-1.0#examples
+ * @default 48
+ */
+ profilePhotoSize?: 48 | 64 | 96 | 120 | 240 | 360 | 432 | 504 | 648
+ /** @default "common" */
+ tenantId?: string
+ }
+): OAuthConfig {
+ const tenant = options.tenantId ?? "common"
+ const profilePhotoSize = options.profilePhotoSize ?? 48
+
+ return {
+ id: "azure-ad",
+ name: "Azure Active Directory",
+ type: "oauth",
+ wellKnown: `https://login.microsoftonline.com/${tenant}/v2.0/.well-known/openid-configuration?appid=${options.clientId}`,
+ authorization: {
+ params: {
+ scope: "openid profile email",
+ },
+ },
+ async profile(profile, tokens) {
+ // https://docs.microsoft.com/en-us/graph/api/profilephoto-get?view=graph-rest-1.0#examples
+ const response = await fetch(
+ `https://graph.microsoft.com/v1.0/me/photos/${profilePhotoSize}x${profilePhotoSize}/$value`,
+ { headers: { Authorization: `Bearer ${tokens.access_token}` } }
+ )
+
+ // Confirm that profile photo was returned
+ let image
+ // TODO: Do this without Buffer
+ if (response.ok && typeof Buffer !== "undefined") {
+ try {
+ const pictureBuffer = await response.arrayBuffer()
+ const pictureBase64 = Buffer.from(pictureBuffer).toString("base64")
+ image = `data:image/jpeg;base64, ${pictureBase64}`
+ } catch {}
+ }
+
+ return {
+ id: profile.sub,
+ name: profile.name,
+ email: profile.email,
+ image: image ?? null,
+ }
+ },
+ style: {
+ logo: "/azure.svg",
+ logoDark: "/azure-dark.svg",
+ bg: "#fff",
+ text: "#0072c6",
+ bgDark: "#0072c6",
+ textDark: "#fff",
+ },
+ options,
+ }
+}
diff --git a/packages/next-auth/src/providers/battlenet.ts b/packages/next-auth/src/providers/battlenet.ts
new file mode 100644
index 00000000..c921d5df
--- /dev/null
+++ b/packages/next-auth/src/providers/battlenet.ts
@@ -0,0 +1,39 @@
+import type { OAuthConfig, OAuthUserConfig } from "."
+
+export interface BattleNetProfile extends Record (
+ options: OAuthUserConfig & { issuer: BattleNetIssuer }
+): OAuthConfig {
+ return {
+ id: "battlenet",
+ name: "Battle.net",
+ type: "oauth",
+ wellKnown: `${options.issuer}/.well-known/openid-configuration`,
+ profile(profile) {
+ return {
+ id: profile.sub,
+ name: profile.battle_tag,
+ email: null,
+ image: null,
+ }
+ },
+ style: {
+ logo: "/battlenet.svg",
+ logoDark: "/battlenet-dark.svg",
+ bg: "#fff",
+ text: "#148eff",
+ bgDark: "#148eff",
+ textDark: "#fff",
+ },
+ options,
+ }
+}
diff --git a/packages/next-auth/src/providers/box.js b/packages/next-auth/src/providers/box.js
new file mode 100644
index 00000000..7d7e8546
--- /dev/null
+++ b/packages/next-auth/src/providers/box.js
@@ -0,0 +1,28 @@
+/** @type {import(".").OAuthProvider} */
+export default function Box(options) {
+ return {
+ id: "box",
+ name: "Box",
+ type: "oauth",
+ authorization: "https://account.box.com/api/oauth2/authorize",
+ token: "https://api.box.com/oauth2/token",
+ userinfo: "https://api.box.com/2.0/users/me",
+ profile(profile) {
+ return {
+ id: profile.id,
+ name: profile.name,
+ email: profile.login,
+ image: profile.avatar_url,
+ }
+ },
+ style: {
+ logo: "/box.svg",
+ logoDark: "/box-dark.svg",
+ bg: "#fff",
+ text: "#0075C9",
+ bgDark: "#0075C9",
+ textDark: "#fff",
+ },
+ options,
+ }
+}
diff --git a/packages/next-auth/src/providers/boxyhq-saml.ts b/packages/next-auth/src/providers/boxyhq-saml.ts
new file mode 100644
index 00000000..a00e15d5
--- /dev/null
+++ b/packages/next-auth/src/providers/boxyhq-saml.ts
@@ -0,0 +1,37 @@
+import type { OAuthConfig, OAuthUserConfig } from "."
+
+export interface BoxyHQSAMLProfile extends Record (
+ options: OAuthUserConfig
+): OAuthConfig {
+ return {
+ id: "boxyhq-saml",
+ name: "BoxyHQ SAML",
+ type: "oauth",
+ version: "2.0",
+ checks: ["pkce", "state"],
+ authorization: {
+ url: `${options.issuer}/api/oauth/authorize`,
+ params: {
+ provider: "saml",
+ },
+ },
+ token: `${options.issuer}/api/oauth/token`,
+ userinfo: `${options.issuer}/api/oauth/userinfo`,
+ profile(profile) {
+ return {
+ id: profile.id,
+ email: profile.email,
+ name: [profile.firstName, profile.lastName].filter(Boolean).join(" "),
+ image: null,
+ }
+ },
+ options,
+ }
+}
diff --git a/packages/next-auth/src/providers/bungie.js b/packages/next-auth/src/providers/bungie.js
new file mode 100644
index 00000000..dc6c998f
--- /dev/null
+++ b/packages/next-auth/src/providers/bungie.js
@@ -0,0 +1,25 @@
+/** @type {import(".").OAuthProvider} */
+export default function Bungie(options) {
+ return {
+ id: "bungie",
+ name: "Bungie",
+ type: "oauth",
+ authorization: "https://www.bungie.net/en/OAuth/Authorize?reauth=true",
+ token: "https://www.bungie.net/platform/app/oauth/token/",
+ userinfo:
+ "https://www.bungie.net/platform/User/GetBungieAccount/{membershipId}/254/",
+ profile(profile) {
+ const { bungieNetUser: user } = profile.Response
+
+ return {
+ id: user.membershipId,
+ name: user.displayName,
+ email: null,
+ image: `https://www.bungie.net${
+ user.profilePicturePath.startsWith("/") ? "" : "/"
+ }${user.profilePicturePath}`,
+ }
+ },
+ options,
+ }
+}
diff --git a/packages/next-auth/src/providers/cognito.ts b/packages/next-auth/src/providers/cognito.ts
new file mode 100644
index 00000000..26b92231
--- /dev/null
+++ b/packages/next-auth/src/providers/cognito.ts
@@ -0,0 +1,37 @@
+import type { OAuthConfig, OAuthUserConfig } from "."
+
+export interface CognitoProfile extends Record (
+ options: OAuthUserConfig
+): OAuthConfig {
+ return {
+ id: "cognito",
+ name: "Cognito",
+ type: "oauth",
+ wellKnown: `${options.issuer}/.well-known/openid-configuration`,
+ idToken: true,
+ profile(profile) {
+ return {
+ id: profile.sub,
+ name: profile.name,
+ email: profile.email,
+ image: profile.picture,
+ }
+ },
+ style: {
+ logo: "/cognito.svg",
+ logoDark: "/cognito.svg",
+ bg: "#fff",
+ text: "#C17B9E",
+ bgDark: "#fff",
+ textDark: "#C17B9E",
+ },
+ options,
+ }
+}
diff --git a/packages/next-auth/src/providers/coinbase.js b/packages/next-auth/src/providers/coinbase.js
new file mode 100644
index 00000000..1a2cb1d4
--- /dev/null
+++ b/packages/next-auth/src/providers/coinbase.js
@@ -0,0 +1,21 @@
+/** @type {import(".").OAuthProvider} */
+export default function Coinbase(options) {
+ return {
+ id: "coinbase",
+ name: "Coinbase",
+ type: "oauth",
+ authorization:
+ "https://www.coinbase.com/oauth/authorize?scope=wallet:user:email+wallet:user:read",
+ token: "https://api.coinbase.com/oauth/token",
+ userinfo: "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/packages/next-auth/src/providers/credentials.ts b/packages/next-auth/src/providers/credentials.ts
new file mode 100644
index 00000000..bd293603
--- /dev/null
+++ b/packages/next-auth/src/providers/credentials.ts
@@ -0,0 +1,45 @@
+import type { RequestInternal } from "../core"
+import type { CommonProviderOptions } from "."
+import type { User, Awaitable } from ".."
+
+export interface CredentialInput {
+ label?: string
+ type?: string
+ value?: string
+ placeholder?: string
+}
+
+export interface CredentialsConfig<
+ C extends Record (
+ options: OAuthUserConfig
+): OAuthConfig {
+ return {
+ id: "discord",
+ name: "Discord",
+ type: "oauth",
+ authorization:
+ "https://discord.com/api/oauth2/authorize?scope=identify+email",
+ token: "https://discord.com/api/oauth2/token",
+ userinfo: "https://discord.com/api/users/@me",
+ profile(profile) {
+ if (profile.avatar === null) {
+ const defaultAvatarNumber = parseInt(profile.discriminator) % 5
+ profile.image_url = `https://cdn.discordapp.com/embed/avatars/${defaultAvatarNumber}.png`
+ } else {
+ const format = profile.avatar.startsWith("a_") ? "gif" : "png"
+ profile.image_url = `https://cdn.discordapp.com/avatars/${profile.id}/${profile.avatar}.${format}`
+ }
+ return {
+ id: profile.id,
+ name: profile.username,
+ email: profile.email,
+ image: profile.image_url,
+ }
+ },
+ style: {
+ logo: "/discord.svg",
+ logoDark: "/discord-dark.svg",
+ bg: "#fff",
+ text: "#7289DA",
+ bgDark: "#7289DA",
+ textDark: "#fff",
+ },
+ options,
+ }
+}
diff --git a/packages/next-auth/src/providers/dropbox.js b/packages/next-auth/src/providers/dropbox.js
new file mode 100644
index 00000000..c1988a62
--- /dev/null
+++ b/packages/next-auth/src/providers/dropbox.js
@@ -0,0 +1,51 @@
+/**
+ * @param {import("../core").Provider} options
+ * @example
+ *
+ * ```js
+ * // pages/api/auth/[...nextauth].js
+ * import Providers from `next-auth/providers`
+ * ...
+ * providers: [
+ * Providers.Dropbox({
+ * clientId: process.env.DROPBOX_CLIENT_ID,
+ * clientSecret: process.env.DROPBOX_CLIENT_SECRET
+ * })
+ * ]
+ * ...
+ *
+ * // pages/index
+ * import { signIn } from "next-auth/react"
+ * ...
+ *
+ * ...
+ * ```
+ * *Resources:*
+ * - [NextAuth.js Documentation](https://next-auth.js.org/providers/dropbox)
+ * - [Dropbox Documentation](https://developers.dropbox.com/oauth-guide)
+ * - [Configuration](https://www.dropbox.com/developers/apps)
+ */
+/** @type {import(".").OAuthProvider} */
+export default function Dropbox(options) {
+ return {
+ id: "dropbox",
+ name: "Dropbox",
+ type: "oauth",
+ authorization:
+ "https://www.dropbox.com/oauth2/authorize?token_access_type=offline&scope=account_info.read",
+ token: "https://api.dropboxapi.com/oauth2/token",
+ userinfo: "https://api.dropboxapi.com/2/users/get_current_account",
+ profile(profile) {
+ return {
+ id: profile.account_id,
+ name: profile.name.display_name,
+ email: profile.email,
+ image: profile.profile_photo_url,
+ }
+ },
+ checks: ["state", "pkce"],
+ options,
+ }
+}
diff --git a/packages/next-auth/src/providers/duende-identity-server6.ts b/packages/next-auth/src/providers/duende-identity-server6.ts
new file mode 100644
index 00000000..fd9f36fb
--- /dev/null
+++ b/packages/next-auth/src/providers/duende-identity-server6.ts
@@ -0,0 +1,31 @@
+import type { OAuthConfig, OAuthUserConfig } from "./oauth"
+
+export interface DuendeISUser extends Record (
+ options: OAuthUserConfig
+): OAuthConfig {
+ return {
+ id: "duende-identityserver6",
+ name: "DuendeIdentityServer6",
+ type: "oauth",
+ wellKnown: `${options.issuer}/.well-known/openid-configuration`,
+ authorization: { params: { scope: "openid profile email" } },
+ checks: ["pkce", "state"],
+ idToken: true,
+ profile(profile) {
+ return {
+ id: profile.sub,
+ name: profile.name,
+ email: profile.email,
+ image: null,
+ }
+ },
+ options,
+ }
+}
diff --git a/packages/next-auth/src/providers/email.ts b/packages/next-auth/src/providers/email.ts
new file mode 100644
index 00000000..b72cc84e
--- /dev/null
+++ b/packages/next-auth/src/providers/email.ts
@@ -0,0 +1,167 @@
+import { createTransport } from "nodemailer"
+
+import type { CommonProviderOptions } from "."
+import type { Options as SMTPTransportOptions } from "nodemailer/lib/smtp-transport"
+import type { Awaitable } from ".."
+import type { Theme } from "../core/types"
+
+export interface SendVerificationRequestParams {
+ identifier: string
+ url: string
+ expires: Date
+ provider: EmailConfig
+ token: string
+ theme: Theme
+}
+
+export interface EmailConfig extends CommonProviderOptions {
+ type: "email"
+ // TODO: Make use of https://www.typescriptlang.org/docs/handbook/2/template-literal-types.html
+ server: string | SMTPTransportOptions
+ /** @default "NextAuth (
+ options: OAuthUserConfig
+): OAuthConfig {
+ return {
+ id: "eveonline",
+ name: "EVE Online",
+ type: "oauth",
+ authorization: "https://login.eveonline.com/v2/oauth/authorize?scope=publicData",
+ token: "https://login.eveonline.com/v2/oauth/token",
+ userinfo: "https://login.eveonline.com/oauth/verify",
+ profile(profile) {
+ return {
+ id: String(profile.CharacterID),
+ name: profile.CharacterName,
+ email: null,
+ image: `https://image.eveonline.com/Character/${profile.CharacterID}_128.jpg`,
+ }
+ },
+ options,
+ }
+}
diff --git a/packages/next-auth/src/providers/facebook.ts b/packages/next-auth/src/providers/facebook.ts
new file mode 100644
index 00000000..a337ecd1
--- /dev/null
+++ b/packages/next-auth/src/providers/facebook.ts
@@ -0,0 +1,54 @@
+import type { OAuthConfig, OAuthUserConfig } from "."
+
+interface FacebookPictureData {
+ url: string
+}
+
+interface FacebookPicture {
+ data: FacebookPictureData
+}
+export interface FacebookProfile extends Record (
+ options: OAuthUserConfig
+): OAuthConfig {
+ return {
+ id: "facebook",
+ name: "Facebook",
+ type: "oauth",
+ authorization: "https://www.facebook.com/v11.0/dialog/oauth?scope=email",
+ token: "https://graph.facebook.com/oauth/access_token",
+ userinfo: {
+ url: "https://graph.facebook.com/me",
+ // https://developers.facebook.com/docs/graph-api/reference/user/#fields
+ params: { fields: "id,name,email,picture" },
+ async request({ tokens, client, provider }) {
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+ return await client.userinfo(tokens.access_token!, {
+ // @ts-expect-error
+ params: provider.userinfo?.params,
+ })
+ },
+ },
+ profile(profile: P) {
+ return {
+ id: profile.id,
+ name: profile.name,
+ email: profile.email,
+ image: profile.picture.data.url,
+ }
+ },
+ style: {
+ logo: "/facebook.svg",
+ logoDark: "/facebook-dark.svg",
+ bg: "#fff",
+ text: "#006aff",
+ bgDark: "#006aff",
+ textDark: "#fff",
+ },
+ options,
+ }
+}
diff --git a/packages/next-auth/src/providers/faceit.js b/packages/next-auth/src/providers/faceit.js
new file mode 100644
index 00000000..503d2823
--- /dev/null
+++ b/packages/next-auth/src/providers/faceit.js
@@ -0,0 +1,25 @@
+/** @type {import(".").OAuthProvider} */
+export default function FACEIT(options) {
+ return {
+ id: "faceit",
+ name: "FACEIT",
+ type: "oauth",
+ authorization: "https://accounts.faceit.com/accounts?redirect_popup=true",
+ headers: {
+ Authorization: `Basic ${Buffer.from(
+ `${options.clientId}:${options.clientSecret}`
+ ).toString("base64")}`,
+ },
+ token: "https://api.faceit.com/auth/v1/oauth/token",
+ userinfo: "https://api.faceit.com/auth/v1/resources/userinfo",
+ profile(profile) {
+ return {
+ id: profile.guid,
+ name: profile.name,
+ email: profile.email,
+ image: profile.picture,
+ }
+ },
+ options,
+ }
+}
diff --git a/packages/next-auth/src/providers/foursquare.js b/packages/next-auth/src/providers/foursquare.js
new file mode 100644
index 00000000..d5a2068b
--- /dev/null
+++ b/packages/next-auth/src/providers/foursquare.js
@@ -0,0 +1,63 @@
+import { get } from "https"
+import { once } from "events"
+
+/** @type {import("src/providers").OAuthProvider} */
+/** @type {import(".").OAuthProvider} */
+export default function Foursquare(options) {
+ const { apiVersion = "20210801" } = options
+ return {
+ id: "foursquare",
+ name: "Foursquare",
+ type: "oauth",
+ authorization: "https://foursquare.com/oauth2/authenticate",
+ token: "https://foursquare.com/oauth2/access_token",
+ userinfo: {
+ async request({ tokens }) {
+ const url = new URL("https://api.foursquare.com/v2/users/self")
+ url.searchParams.append("v", apiVersion)
+ url.searchParams.append("oauth_token", tokens.access_token)
+
+ const req = get(url, { timeout: 3500 })
+ const [response] = await Promise.race([
+ once(req, "response"),
+ once(req, "timeout"),
+ ])
+
+ // timeout reached
+ if (!response) {
+ req.destroy()
+ throw new Error("HTTP Request Timed Out")
+ }
+ if (response.statusCode !== 200) {
+ throw new Error("Expected 200 OK from the userinfo endpoint")
+ }
+
+ const parts = []
+ for await (const part of response) {
+ parts.push(part)
+ }
+
+ return JSON.parse(Buffer.concat(parts))
+ },
+ },
+ profile({ response: { profile } }) {
+ return {
+ id: profile.id,
+ name: `${profile.firstName} ${profile.lastName}`,
+ email: profile.contact.email,
+ image: profile.photo
+ ? `${profile.photo.prefix}original${profile.photo.suffix}`
+ : null,
+ }
+ },
+ style: {
+ logo: "/foursquare.svg",
+ logoDark: "/foursquare-dark.svg",
+ bg: "#fff",
+ text: "#000",
+ bgDark: "#000",
+ textDark: "#fff",
+ },
+ options,
+ }
+}
diff --git a/packages/next-auth/src/providers/freshbooks.js b/packages/next-auth/src/providers/freshbooks.js
new file mode 100644
index 00000000..b253ed38
--- /dev/null
+++ b/packages/next-auth/src/providers/freshbooks.js
@@ -0,0 +1,30 @@
+/** @type {import(".").OAuthProvider} */
+export default function Freshbooks(options) {
+ return {
+ id: "freshbooks",
+ name: "Freshbooks",
+ type: "oauth",
+ version: "2.0",
+ params: { grant_type: "authorization_code" },
+ accessTokenUrl: "https://api.freshbooks.com/auth/oauth/token",
+ authorizationUrl:
+ "https://auth.freshbooks.com/service/auth/oauth/authorize?response_type=code",
+ profileUrl: "https://api.freshbooks.com/auth/api/v1/users/me",
+ async profile(profile) {
+ return {
+ id: profile.response.id,
+ name: `${profile.response.first_name} ${profile.response.last_name}`,
+ email: profile.response.email,
+ }
+ },
+ style: {
+ logo: "/freshbooks.svg",
+ logoDark: "/freshbooks-dark.svg",
+ bg: "#fff",
+ text: "#0075dd",
+ bgDark: "#0075dd",
+ textDark: "#fff",
+ },
+ ...options,
+ }
+}
diff --git a/packages/next-auth/src/providers/fusionauth.ts b/packages/next-auth/src/providers/fusionauth.ts
new file mode 100644
index 00000000..7b3ea6e1
--- /dev/null
+++ b/packages/next-auth/src/providers/fusionauth.ts
@@ -0,0 +1,50 @@
+import { OAuthConfig, OAuthUserConfig } from "./oauth"
+
+/** This is the default openid signature returned from FusionAuth
+ * it can be customized using [lambda functions](https://fusionauth.io/docs/v1/tech/lambdas)
+ */
+export interface FusionAuthProfile extends Record (
+ // tenantId only needed if there is more than one tenant configured on the server
+ options: OAuthUserConfig & { tenantId?: string }
+): OAuthConfig {
+ return {
+ id: "fusionauth",
+ name: "FusionAuth",
+ type: "oauth",
+ wellKnown: options?.tenantId
+ ? `${options.issuer}/.well-known/openid-configuration?tenantId=${options.tenantId}`
+ : `${options.issuer}/.well-known/openid-configuration`,
+ authorization: {
+ params: {
+ scope: "openid offline_access",
+ ...(options?.tenantId && { tenantId: options.tenantId }),
+ },
+ },
+ checks: ["pkce", "state"],
+ profile(profile) {
+ return {
+ id: profile.sub,
+ email: profile.email,
+ name: profile?.preferred_username,
+ }
+ },
+ options,
+ }
+}
diff --git a/packages/next-auth/src/providers/github.ts b/packages/next-auth/src/providers/github.ts
new file mode 100644
index 00000000..5591ab0a
--- /dev/null
+++ b/packages/next-auth/src/providers/github.ts
@@ -0,0 +1,111 @@
+import type { OAuthConfig, OAuthUserConfig } from "."
+
+/** @see https://docs.github.com/en/rest/users/users#get-the-authenticated-user */
+export interface GithubProfile extends Record (
+ options: OAuthUserConfig
+): OAuthConfig {
+ return {
+ id: "github",
+ name: "GitHub",
+ type: "oauth",
+ authorization: {
+ url: "https://github.com/login/oauth/authorize",
+ params: { scope: "read:user user:email" },
+ },
+ token: "https://github.com/login/oauth/access_token",
+ userinfo: {
+ url: "https://api.github.com/user",
+ async request({ client, tokens }) {
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+ const profile = await client.userinfo(tokens.access_token!)
+
+ if (!profile.email) {
+ // If the user does not have a public email, get another via the GitHub API
+ // See https://docs.github.com/en/rest/users/emails#list-public-email-addresses-for-the-authenticated-user
+ const res = await fetch("https://api.github.com/user/emails", {
+ headers: { Authorization: `token ${tokens.access_token}` },
+ })
+
+ if (res.ok) {
+ const emails: GithubEmail[] = await res.json()
+ profile.email = (emails.find((e) => e.primary) ?? emails[0]).email
+ }
+ }
+
+ return profile
+ },
+ },
+ profile(profile) {
+ return {
+ id: profile.id.toString(),
+ name: profile.name ?? profile.login,
+ email: profile.email,
+ image: profile.avatar_url,
+ }
+ },
+ style: {
+ logo: "/github.svg",
+ logoDark: "/github-dark.svg",
+ bg: "#fff",
+ bgDark: "#000",
+ text: "#000",
+ textDark: "#fff",
+ },
+ options,
+ }
+}
diff --git a/packages/next-auth/src/providers/gitlab.ts b/packages/next-auth/src/providers/gitlab.ts
new file mode 100644
index 00000000..65f0fd8d
--- /dev/null
+++ b/packages/next-auth/src/providers/gitlab.ts
@@ -0,0 +1,80 @@
+import type { OAuthConfig, OAuthUserConfig } from "."
+
+export interface GitLabProfile extends Record (
+ options: OAuthUserConfig
+): OAuthConfig {
+ return {
+ id: "gitlab",
+ name: "GitLab",
+ type: "oauth",
+ authorization: {
+ url: "https://gitlab.com/oauth/authorize",
+ params: { scope: "read_user" },
+ },
+ token: "https://gitlab.com/oauth/token",
+ userinfo: "https://gitlab.com/api/v4/user",
+ checks: ["pkce", "state"],
+ profile(profile) {
+ return {
+ id: profile.id.toString(),
+ name: profile.name ?? profile.username,
+ email: profile.email,
+ image: profile.avatar_url,
+ }
+ },
+ style: {
+ logo: "/gitlab.svg",
+ logoDark: "/gitlab-dark.svg",
+ bg: "#fff",
+ text: "#FC6D26",
+ bgDark: "#FC6D26",
+ textDark: "#fff",
+ },
+ options,
+ }
+}
diff --git a/packages/next-auth/src/providers/google.ts b/packages/next-auth/src/providers/google.ts
new file mode 100644
index 00000000..49bcf295
--- /dev/null
+++ b/packages/next-auth/src/providers/google.ts
@@ -0,0 +1,50 @@
+import type { OAuthConfig, OAuthUserConfig } from "."
+
+export interface GoogleProfile extends Record (
+ options: OAuthUserConfig
+): OAuthConfig {
+ return {
+ id: "google",
+ name: "Google",
+ type: "oauth",
+ wellKnown: "https://accounts.google.com/.well-known/openid-configuration",
+ authorization: { params: { scope: "openid email profile" } },
+ idToken: true,
+ checks: ["pkce", "state"],
+ profile(profile) {
+ return {
+ id: profile.sub,
+ name: profile.name,
+ email: profile.email,
+ image: profile.picture,
+ }
+ },
+ style: {
+ logo: "/google.svg",
+ logoDark: "/google.svg",
+ bgDark: "#fff",
+ bg: "#fff",
+ text: "#000",
+ textDark: "#000",
+ },
+ options,
+ }
+}
diff --git a/packages/next-auth/src/providers/hubspot.ts b/packages/next-auth/src/providers/hubspot.ts
new file mode 100644
index 00000000..ed5d9b6c
--- /dev/null
+++ b/packages/next-auth/src/providers/hubspot.ts
@@ -0,0 +1,77 @@
+import type { OAuthConfig, OAuthUserConfig } from "."
+
+interface HubSpotProfile extends Record (
+ options: OAuthUserConfig
+): OAuthConfig {
+ return {
+ id: "hubspot",
+ name: "HubSpot",
+ type: "oauth",
+
+ ...HubSpotConfig,
+
+ authorization: {
+ url: HubSpotConfig.authorizationUrl,
+ params: {
+ scope: "oauth",
+ client_id: options.clientId,
+ },
+ },
+ client: {
+ token_endpoint_auth_method: "client_secret_post",
+ },
+ token: HubSpotConfig.tokenUrl,
+ userinfo: {
+ url: HubSpotConfig.profileUrl,
+ async request(context) {
+ const url = `${HubSpotConfig.profileUrl}/${context.tokens.access_token}`
+
+ const response = await fetch(url, {
+ headers: {
+ "Content-Type": "application/json",
+ },
+ method: "GET",
+ })
+
+ return await response.json()
+ },
+ },
+ profile(profile) {
+ return {
+ id: profile.user_id,
+ name: profile.user,
+ email: profile.user,
+
+ // TODO: get image from profile once it's available
+ // Details available https://community.hubspot.com/t5/APIs-Integrations/Profile-photo-is-not-retrieved-with-User-API/m-p/325521
+ image: null,
+ }
+ },
+ style: {
+ logo: "/hubspot.svg",
+ logoDark: "/hubspot-dark.svg",
+ bg: "#fff",
+ text: "#ff7a59",
+ bgDark: "#ff7a59",
+ textDark: "#fff",
+ },
+ options,
+ }
+}
diff --git a/packages/next-auth/src/providers/identity-server4.js b/packages/next-auth/src/providers/identity-server4.js
new file mode 100644
index 00000000..b105712b
--- /dev/null
+++ b/packages/next-auth/src/providers/identity-server4.js
@@ -0,0 +1,21 @@
+/** @type {import(".").OAuthProvider} */
+export default function IdentityServer4(options) {
+ return {
+ id: "identity-server4",
+ name: "IdentityServer4",
+ type: "oauth",
+ wellKnown: `${options.issuer}/.well-known/openid-configuration`,
+ authorization: { params: { scope: "openid profile email" } },
+ checks: ["pkce", "state"],
+ idToken: true,
+ profile(profile) {
+ return {
+ id: profile.sub,
+ name: profile.name,
+ email: profile.email,
+ image: null,
+ }
+ },
+ options,
+ }
+}
diff --git a/packages/next-auth/src/providers/index.ts b/packages/next-auth/src/providers/index.ts
new file mode 100644
index 00000000..96aaa95f
--- /dev/null
+++ b/packages/next-auth/src/providers/index.ts
@@ -0,0 +1,41 @@
+import type { OAuthConfig, OAuthProvider, OAuthProviderType } from "./oauth"
+
+import type { EmailConfig, EmailProvider, EmailProviderType } from "./email"
+
+import type {
+ CredentialsConfig,
+ CredentialsProvider,
+ CredentialsProviderType,
+} from "./credentials"
+
+export * from "./oauth"
+export * from "./email"
+export * from "./credentials"
+
+export type ProviderType = "oauth" | "email" | "credentials"
+
+export interface CommonProviderOptions {
+ id: string
+ name: string
+ type: ProviderType
+ options?: Record (
+ options: OAuthUserConfig
+): OAuthConfig {
+ return {
+ id: "kakao",
+ name: "Kakao",
+ type: "oauth",
+ authorization: "https://kauth.kakao.com/oauth/authorize?scope",
+ token: "https://kauth.kakao.com/oauth/token",
+ userinfo: "https://kapi.kakao.com/v2/user/me",
+ client: {
+ token_endpoint_auth_method: "client_secret_post",
+ },
+ profile(profile) {
+ return {
+ id: String(profile.id),
+ name: profile.kakao_account?.profile?.nickname,
+ email: profile.kakao_account?.email,
+ image: profile.kakao_account?.profile?.profile_image_url,
+ }
+ },
+ options,
+ }
+}
diff --git a/packages/next-auth/src/providers/keycloak.ts b/packages/next-auth/src/providers/keycloak.ts
new file mode 100644
index 00000000..e3cfb5b3
--- /dev/null
+++ b/packages/next-auth/src/providers/keycloak.ts
@@ -0,0 +1,56 @@
+import type { OAuthConfig, OAuthUserConfig } from "."
+
+export interface KeycloakProfile extends Record (
+ options: OAuthUserConfig
+): OAuthConfig {
+ return {
+ id: "keycloak",
+ name: "Keycloak",
+ wellKnown: `${options.issuer}/.well-known/openid-configuration`,
+ type: "oauth",
+ authorization: { params: { scope: "openid email profile" } },
+ checks: ["pkce", "state"],
+ idToken: true,
+ profile(profile) {
+ return {
+ id: profile.sub,
+ name: profile.name ?? profile.preferred_username,
+ email: profile.email,
+ image: profile.picture,
+ }
+ },
+ style: {
+ logo: "/keycloak.svg",
+ logoDark: "/keycloak.svg",
+ bg: "#fff",
+ text: "#000",
+ bgDark: "#fff",
+ textDark: "#000",
+ },
+ options,
+ }
+}
diff --git a/packages/next-auth/src/providers/line.ts b/packages/next-auth/src/providers/line.ts
new file mode 100644
index 00000000..417680e7
--- /dev/null
+++ b/packages/next-auth/src/providers/line.ts
@@ -0,0 +1,46 @@
+import type { OAuthConfig, OAuthUserConfig } from "."
+
+export interface LineProfile extends Record (
+ options: OAuthUserConfig
+): OAuthConfig {
+ return {
+ id: "line",
+ name: "LINE",
+ type: "oauth",
+ authorization: { params: { scope: "openid profile" } },
+ idToken: true,
+ wellKnown: "https://access.line.me/.well-known/openid-configuration",
+ profile(profile) {
+ return {
+ id: profile.sub,
+ name: profile.name,
+ email: profile.email,
+ image: profile.picture,
+ }
+ },
+ client: {
+ id_token_signed_response_alg: "HS256",
+ },
+ style: {
+ logo: "/line.svg",
+ logoDark: "/line.svg",
+ bg: "#fff",
+ text: "#00C300",
+ bgDark: "#00C300",
+ textDark: "#fff",
+ },
+ options,
+ }
+}
diff --git a/packages/next-auth/src/providers/linkedin.ts b/packages/next-auth/src/providers/linkedin.ts
new file mode 100644
index 00000000..deb63cd0
--- /dev/null
+++ b/packages/next-auth/src/providers/linkedin.ts
@@ -0,0 +1,68 @@
+import type { OAuthConfig, OAuthUserConfig } from "."
+
+interface Identifier {
+ identifier: string
+}
+
+interface Element {
+ identifiers?: Identifier[]
+}
+
+export interface LinkedInProfile extends Record (
+ options: OAuthUserConfig
+): OAuthConfig {
+ return {
+ id: "linkedin",
+ name: "LinkedIn",
+ type: "oauth",
+ authorization: {
+ url: "https://www.linkedin.com/oauth/v2/authorization",
+ params: { scope: "r_liteprofile r_emailaddress" },
+ },
+ token: "https://www.linkedin.com/oauth/v2/accessToken",
+ client: {
+ token_endpoint_auth_method: "client_secret_post",
+ },
+ userinfo: {
+ url: "https://api.linkedin.com/v2/me",
+ params: {
+ projection: `(id,localizedFirstName,localizedLastName,profilePicture(displayImage~digitalmediaAsset:playableStreams))`,
+ },
+ },
+ async profile(profile, tokens) {
+ const emailResponse = await fetch(
+ "https://api.linkedin.com/v2/emailAddress?q=members&projection=(elements*(handle~))",
+ { headers: { Authorization: `Bearer ${tokens.access_token}` } }
+ )
+ const emailData = await emailResponse.json()
+ return {
+ id: profile.id,
+ name: `${profile.localizedFirstName} ${profile.localizedLastName}`,
+ email: emailData?.elements?.[0]?.["handle~"]?.emailAddress,
+ image:
+ profile.profilePicture?.["displayImage~"]?.elements?.[0]
+ ?.identifiers?.[0]?.identifier,
+ }
+ },
+ style: {
+ logo: "/linkedin.svg",
+ logoDark: "/linkedin-dark.svg",
+ bg: "#fff",
+ text: "#069",
+ bgDark: "#069",
+ textDark: "#fff",
+ },
+ options,
+ }
+}
diff --git a/packages/next-auth/src/providers/mailchimp.js b/packages/next-auth/src/providers/mailchimp.js
new file mode 100644
index 00000000..4e4fac04
--- /dev/null
+++ b/packages/next-auth/src/providers/mailchimp.js
@@ -0,0 +1,28 @@
+/** @type {import(".").OAuthProvider} */
+export default function Mailchimp(options) {
+ return {
+ id: "mailchimp",
+ name: "Mailchimp",
+ type: "oauth",
+ authorization: "https://login.mailchimp.com/oauth2/authorize",
+ token: "https://login.mailchimp.com/oauth2/token",
+ userinfo: "https://login.mailchimp.com/oauth2/metadata",
+ profile(profile) {
+ return {
+ id: profile.login.login_id,
+ name: profile.accountname,
+ email: profile.login.email,
+ image: null,
+ }
+ },
+ style: {
+ logo: "/mailchimp.svg",
+ logoDark: "/mailchimp-dark.svg",
+ bg: "#fff",
+ text: "#000",
+ bgDark: "#000",
+ textDark: "#fff",
+ },
+ options,
+ }
+}
diff --git a/packages/next-auth/src/providers/mailru.js b/packages/next-auth/src/providers/mailru.js
new file mode 100644
index 00000000..eee3cfd5
--- /dev/null
+++ b/packages/next-auth/src/providers/mailru.js
@@ -0,0 +1,20 @@
+/** @type {import(".").OAuthProvider} */
+export default function MailRu(options) {
+ return {
+ id: "mailru",
+ name: "Mail.ru",
+ type: "oauth",
+ authorization: "https://oauth.mail.ru/login?scope=userinfo",
+ token: "https://oauth.mail.ru/token",
+ userinfo: "https://oauth.mail.ru/userinfo",
+ profile(profile) {
+ return {
+ id: profile.id,
+ name: profile.name,
+ email: profile.email,
+ image: profile.image,
+ }
+ },
+ options,
+ }
+}
diff --git a/packages/next-auth/src/providers/medium.js b/packages/next-auth/src/providers/medium.js
new file mode 100644
index 00000000..4ec8792f
--- /dev/null
+++ b/packages/next-auth/src/providers/medium.js
@@ -0,0 +1,20 @@
+/** @type {import(".").OAuthProvider} */
+export default function Medium(options) {
+ return {
+ id: "medium",
+ name: "Medium",
+ type: "oauth",
+ authorization: "https://medium.com/m/oauth/authorize?scope=basicProfile",
+ token: "https://api.medium.com/v1/tokens",
+ userinfo: "https://api.medium.com/v1/me",
+ profile(profile) {
+ return {
+ id: profile.data.id,
+ name: profile.data.name,
+ email: null,
+ image: profile.data.imageUrl,
+ }
+ },
+ options,
+ }
+}
diff --git a/packages/next-auth/src/providers/naver.ts b/packages/next-auth/src/providers/naver.ts
new file mode 100644
index 00000000..301eff28
--- /dev/null
+++ b/packages/next-auth/src/providers/naver.ts
@@ -0,0 +1,42 @@
+import type { OAuthConfig, OAuthUserConfig } from "."
+
+/** https://developers.naver.com/docs/login/profile/profile.md */
+export interface NaverProfile extends Record (
+ options: OAuthUserConfig
+): OAuthConfig {
+ return {
+ id: "naver",
+ name: "Naver",
+ type: "oauth",
+ authorization: "https://nid.naver.com/oauth2.0/authorize",
+ token: "https://nid.naver.com/oauth2.0/token",
+ userinfo: "https://openapi.naver.com/v1/nid/me",
+ profile(profile) {
+ return {
+ id: profile.response.id,
+ name: profile.response.nickname,
+ email: profile.response.email,
+ image: profile.response.profile_image,
+ }
+ },
+ checks: ["state"],
+ options,
+ }
+}
diff --git a/packages/next-auth/src/providers/netlify.js b/packages/next-auth/src/providers/netlify.js
new file mode 100644
index 00000000..7c31aed0
--- /dev/null
+++ b/packages/next-auth/src/providers/netlify.js
@@ -0,0 +1,20 @@
+/** @type {import(".").OAuthProvider} */
+export default function Netlify(options) {
+ return {
+ id: "netlify",
+ name: "Netlify",
+ type: "oauth",
+ authorization: "https://app.netlify.com/authorize",
+ token: "https://api.netlify.com/oauth/token",
+ userinfo: "https://api.netlify.com/api/v1/user",
+ profile(profile) {
+ return {
+ id: profile.id,
+ name: profile.full_name,
+ email: profile.email,
+ image: profile.avatar_url,
+ }
+ },
+ options,
+ }
+}
diff --git a/packages/next-auth/src/providers/oauth.ts b/packages/next-auth/src/providers/oauth.ts
new file mode 100644
index 00000000..e19d6543
--- /dev/null
+++ b/packages/next-auth/src/providers/oauth.ts
@@ -0,0 +1,171 @@
+import type { CommonProviderOptions } from "../providers"
+import type { Profile, TokenSet, User, Awaitable } from ".."
+
+import type {
+ AuthorizationParameters,
+ CallbackParamsType,
+ Issuer,
+ ClientMetadata,
+ IssuerMetadata,
+ OAuthCallbackChecks,
+ OpenIDCallbackChecks,
+ HttpOptions,
+} from "openid-client"
+import type { JWK } from "jose"
+
+type Client = InstanceType & {
+ signinUrl: string
+ callbackUrl: string
+ }
+ }
+) => Awaitable {
+ /** Endpoint URL. Can contain parameters. Optionally, you can use `params` */
+ url?: string
+ /** These will be prepended to the `url` */
+ params?: P
+ /**
+ * Control the corresponding OAuth endpoint request completely.
+ * Useful if your provider relies on some custom behaviour
+ * or it diverges from the OAuth spec.
+ *
+ * - ⚠ **This is an advanced option.**
+ * You should **try to avoid using advanced options** unless you are very comfortable using them.
+ */
+ request?: EndpointRequest
+
+export type AuthorizationEndpointHandler =
+ EndpointHandler extends CommonProviderOptions, PartialIssuer {
+ /**
+ * OpenID Connect (OIDC) compliant providers can configure
+ * this instead of `authorize`/`token`/`userinfo` options
+ * without further configuration needed in most cases.
+ * You can still use the `authorize`/`token`/`userinfo`
+ * options for advanced control.
+ *
+ * [Authorization Server Metadata](https://datatracker.ietf.org/doc/html/rfc8414#section-3)
+ */
+ wellKnown?: string
+ jwks_endpoint?: string
+ /**
+ * The login process will be initiated by sending the user to this URL.
+ *
+ * [Authorization endpoint](https://datatracker.ietf.org/doc/html/rfc6749#section-3.1)
+ */
+ authorization?: string | AuthorizationEndpointHandler
+ token?: string | TokenEndpointHandler
+ userinfo?: string | UserinfoEndpointHandler
+ type: "oauth"
+ version?: string
+ profile: (profile: P, tokens: TokenSet) => Awaitable
+
+ // These are kept around for backwards compatibility with OAuth 1.x
+ accessTokenUrl?: string
+ requestTokenUrl?: string
+ profileUrl?: string
+ encoding?: string
+ allowDangerousEmailAccountLinking?: boolean
+}
+
+export type OAuthUserConfig = Omit<
+ Partial (
+ options: OAuthUserConfig
+): OAuthConfig {
+ return {
+ id: "okta",
+ name: "Okta",
+ type: "oauth",
+ wellKnown: `${options.issuer}/.well-known/openid-configuration`,
+ authorization: { params: { scope: "openid email profile" } },
+ idToken: true,
+ profile(profile) {
+ return {
+ id: profile.sub,
+ name: profile.name ?? profile.preferred_username,
+ email: profile.email,
+ image: profile.picture,
+ }
+ },
+ style: {
+ logo: "/okta.svg",
+ logoDark: "/okta-dark.svg",
+ bg: "#fff",
+ text: "#000",
+ bgDark: "#000",
+ textDark: "#fff",
+ },
+ options,
+ }
+}
diff --git a/packages/next-auth/src/providers/onelogin.js b/packages/next-auth/src/providers/onelogin.js
new file mode 100644
index 00000000..c8593ae6
--- /dev/null
+++ b/packages/next-auth/src/providers/onelogin.js
@@ -0,0 +1,20 @@
+/** @type {import(".").OAuthProvider} */
+export default function OneLogin(options) {
+ return {
+ id: "onelogin",
+ name: "OneLogin",
+ type: "oauth",
+ wellKnown: `${options.issuer}/oidc/2/.well-known/openid-configuration`,
+ authorization: { params: { scope: "openid profile email" } },
+ idToken: true,
+ profile(profile) {
+ return {
+ id: profile.sub,
+ name: profile.nickname,
+ email: profile.email,
+ image: profile.picture,
+ }
+ },
+ options,
+ }
+}
diff --git a/packages/next-auth/src/providers/osso.js b/packages/next-auth/src/providers/osso.js
new file mode 100644
index 00000000..6a1e001b
--- /dev/null
+++ b/packages/next-auth/src/providers/osso.js
@@ -0,0 +1,20 @@
+/** @type {import(".").OAuthProvider} */
+export default function Osso(options) {
+ return {
+ id: "osso",
+ name: "Osso",
+ type: "oauth",
+ authorization: `${options.issuer}oauth/authorize`,
+ token: `${options.issuer}oauth/token`,
+ userinfo: `${options.issuer}oauth/me`,
+ profile(profile) {
+ return {
+ id: profile.id,
+ name: profile.name,
+ email: profile.email,
+ image: null,
+ }
+ },
+ options,
+ }
+}
diff --git a/packages/next-auth/src/providers/osu.ts b/packages/next-auth/src/providers/osu.ts
new file mode 100644
index 00000000..c2790008
--- /dev/null
+++ b/packages/next-auth/src/providers/osu.ts
@@ -0,0 +1,77 @@
+import type { OAuthConfig, OAuthUserConfig } from "."
+
+export interface OsuUserCompact {
+ avatar_url: string
+ country_code: string
+ default_group: string
+ id: string
+ is_active: boolean
+ is_bot: boolean
+ is_deleted: boolean
+ is_online: boolean
+ is_supporter: boolean
+ last_visit: Date | null
+ pm_friends_only: boolean
+ profile_colour: string | null
+ username: string
+}
+
+export interface OsuProfile extends OsuUserCompact, Record
+
+
+`
+}
+
+/** Email Text body (fallback for email clients that don't render HTML, e.g. feature phones) */
+function text({ url, host }: { url: string; host: string }) {
+ return `Sign in to ${host}\n${url}\n\n`
+}
diff --git a/packages/next-auth/src/providers/eveonline.ts b/packages/next-auth/src/providers/eveonline.ts
new file mode 100644
index 00000000..c75bc9ec
--- /dev/null
+++ b/packages/next-auth/src/providers/eveonline.ts
@@ -0,0 +1,33 @@
+import type { OAuthConfig, OAuthUserConfig } from "."
+
+export interface EVEOnlineProfile extends Record
+
+
+ Sign in to ${escapedHost}
+
+
+
+
+
+
+
+
+
+ Sign
+ in
+
+
+
+ If you did not request this email you can safely ignore it.
+
+