From 0bff4af96bd46c44ecd6f2006a0804cc7f2f9e3f Mon Sep 17 00:00:00 2001 From: Ephraim Duncan <55143799+ephraimduncan@users.noreply.github.com> Date: Tue, 8 Jul 2025 01:53:52 +0000 Subject: [PATCH] feat: notion provider (#3068) * feat: add notion provider * chore: lint * chore: add docs for notion provider --------- Co-authored-by: Bereket Engida <86073083+Bekacru@users.noreply.github.com> --- docs/components/side-bar.tsx | 14 +-- docs/components/sidebar-content.tsx | 24 +++- docs/content/docs/authentication/notion.mdx | 82 ++++++++++++++ .../better-auth/src/social-providers/index.ts | 30 +++-- .../src/social-providers/notion.ts | 103 ++++++++++++++++++ 5 files changed, 234 insertions(+), 19 deletions(-) create mode 100644 docs/content/docs/authentication/notion.mdx create mode 100644 packages/better-auth/src/social-providers/notion.ts diff --git a/docs/components/side-bar.tsx b/docs/components/side-bar.tsx index 45c39132..88d7705a 100644 --- a/docs/components/side-bar.tsx +++ b/docs/components/side-bar.tsx @@ -1,15 +1,15 @@ "use client"; -import { AnimatePresence, motion, MotionConfig } from "framer-motion"; import { AsideLink } from "@/components/ui/aside-link"; -import { Suspense, useEffect, useState } from "react"; -import { useSearchContext } from "fumadocs-ui/provider"; -import { usePathname, useRouter } from "next/navigation"; -import { contents, examples } from "./sidebar-content"; -import { ChevronDownIcon, Search } from "lucide-react"; -import { Select, SelectContent, SelectItem, SelectTrigger } from "./ui/select"; import { cn } from "@/lib/utils"; +import { AnimatePresence, motion, MotionConfig } from "framer-motion"; +import { useSearchContext } from "fumadocs-ui/provider"; +import { ChevronDownIcon, Search } from "lucide-react"; +import { usePathname, useRouter } from "next/navigation"; +import { Suspense, useEffect, useState } from "react"; +import { contents, examples } from "./sidebar-content"; import { Badge } from "./ui/badge"; +import { Select, SelectContent, SelectItem, SelectTrigger } from "./ui/select"; export default function ArticleLayout() { const [currentOpen, setCurrentOpen] = useState(0); diff --git a/docs/components/sidebar-content.tsx b/docs/components/sidebar-content.tsx index 9d1c6a8e..9b5edb2d 100644 --- a/docs/components/sidebar-content.tsx +++ b/docs/components/sidebar-content.tsx @@ -1,7 +1,10 @@ import { + Book, CircleHelp, + Database, Gauge, Key, + KeyRound, LucideAArrowDown, LucideIcon, Mail, @@ -12,9 +15,6 @@ import { UserCircle, Users2, UserSquare2, - Database, - KeyRound, - Book, } from "lucide-react"; import { ReactNode, SVGProps } from "react"; import { Icons } from "./icons"; @@ -567,6 +567,24 @@ export const contents: Content[] = [ ), }, + { + title: "Notion", + href: "/docs/authentication/notion", + isNew: true, + icon: () => ( + + + + ), + }, { title: "Tiktok", href: "/docs/authentication/tiktok", diff --git a/docs/content/docs/authentication/notion.mdx b/docs/content/docs/authentication/notion.mdx new file mode 100644 index 00000000..78dc4695 --- /dev/null +++ b/docs/content/docs/authentication/notion.mdx @@ -0,0 +1,82 @@ +--- +title: Notion +description: Notion provider setup and usage. +--- + + + + ### Get your Notion credentials + To use Notion as a social provider, you need to get your Notion OAuth credentials. You can get them by creating a new integration in the [Notion Developers Portal](https://www.notion.so/my-integrations). + + In the Notion integration settings > OAuth Domain & URIs, make sure to set the redirect URL to `http://localhost:3000/api/auth/callback/notion` for local development. For production, make sure to set the redirect URL as your application domain, e.g. `https://example.com/api/auth/callback/notion`. If you change the base path of the auth routes, you should update the redirect URL accordingly. + + + Make sure your Notion integration has the appropriate capabilities enabled. For user authentication, you'll need the "Read user information including email addresses" capability. + + + + + ### Configure the provider + To configure the provider, you need to pass the `clientId` and `clientSecret` to `socialProviders.notion` in your auth configuration. + + ```ts title="auth.ts" + import { betterAuth } from "better-auth" + + export const auth = betterAuth({ + socialProviders: { + notion: { // [!code highlight] + clientId: process.env.NOTION_CLIENT_ID as string, // [!code highlight] + clientSecret: process.env.NOTION_CLIENT_SECRET as string, // [!code highlight] + }, // [!code highlight] + }, + }) + ``` + + + +## Usage + +### Sign In with Notion + +To sign in with Notion, you can use the `signIn.social` function provided by the client. The `signIn` function takes an object with the following properties: +- `provider`: The provider to use. It should be set to `notion`. + +```ts title="auth-client.ts" +import { createAuthClient } from "better-auth/client" +const authClient = createAuthClient() + +const signIn = async () => { + const data = await authClient.signIn.social({ + provider: "notion" + }) +} +``` + +### Notion Integration Types + +Notion supports different integration types. When creating your integration, you can choose between: + +- **Public integrations**: Can be installed by any Notion workspace +- **Internal integrations**: Limited to your own workspace + +For most authentication use cases, you'll want to create a public integration to allow users from different workspaces to sign in. + +### Requesting Additional Notion Scopes + +If your application needs additional Notion capabilities after the user has already signed up, you can request them using the `linkSocial` method with the same Notion provider and additional scopes. + +```ts title="auth-client.ts" +const requestNotionAccess = async () => { + await authClient.linkSocial({ + provider: "notion", + // Notion automatically provides access based on integration capabilities + }); +}; + +// Example usage in a React component +return ; +``` + + +After authentication, you can use the access token to interact with the Notion API to read and write pages, databases, and other content that the user has granted access to. + \ No newline at end of file diff --git a/packages/better-auth/src/social-providers/index.ts b/packages/better-auth/src/social-providers/index.ts index d25b6612..0255979a 100644 --- a/packages/better-auth/src/social-providers/index.ts +++ b/packages/better-auth/src/social-providers/index.ts @@ -1,12 +1,21 @@ +import { z } from "zod"; import type { Prettify } from "../types/helper"; import { apple } from "./apple"; import { discord } from "./discord"; +import { dropbox } from "./dropbox"; import { facebook } from "./facebook"; import { github } from "./github"; +import { gitlab } from "./gitlab"; import { google } from "./google"; +import { kick } from "./kick"; +import { linkedin } from "./linkedin"; import { huggingface } from "./huggingface"; import { microsoft } from "./microsoft-entra-id"; +import { notion } from "./notion"; +import { reddit } from "./reddit"; +import { roblox } from "./roblox"; import { spotify } from "./spotify"; +import { tiktok } from "./tiktok"; import { twitch } from "./twitch"; import { twitter } from "./twitter"; import { dropbox } from "./dropbox"; @@ -18,7 +27,6 @@ import { reddit } from "./reddit"; import { roblox } from "./roblox"; import { z } from "zod"; import { vk } from "./vk"; -import { kick } from "./kick"; import { zoom } from "./zoom"; export const socialProviders = { apple, @@ -41,6 +49,7 @@ export const socialProviders = { roblox, vk, zoom, + notion, }; export const socialProviderList = Object.keys(socialProviders) as [ @@ -62,22 +71,25 @@ export type SocialProviders = { >; }; -export * from "./github"; -export * from "./google"; export * from "./apple"; -export * from "./microsoft-entra-id"; export * from "./discord"; -export * from "./spotify"; -export * from "./twitch"; -export * from "./facebook"; -export * from "./twitter"; export * from "./dropbox"; +export * from "./facebook"; +export * from "./github"; export * from "./linear"; export * from "./linkedin"; export * from "./gitlab"; -export * from "./tiktok"; +export * from "./google"; +export * from "./kick"; +export * from "./linkedin"; +export * from "./microsoft-entra-id"; +export * from "./notion"; export * from "./reddit"; export * from "./roblox"; +export * from "./spotify"; +export * from "./tiktok"; +export * from "./twitch"; +export * from "./twitter"; export * from "./vk"; export * from "./zoom"; export * from "./kick"; diff --git a/packages/better-auth/src/social-providers/notion.ts b/packages/better-auth/src/social-providers/notion.ts new file mode 100644 index 00000000..f0325a7b --- /dev/null +++ b/packages/better-auth/src/social-providers/notion.ts @@ -0,0 +1,103 @@ +import { betterFetch } from "@better-fetch/fetch"; +import type { OAuthProvider, ProviderOptions } from "../oauth2"; +import { + createAuthorizationURL, + refreshAccessToken, + validateAuthorizationCode, +} from "../oauth2"; + +export interface NotionProfile { + object: "user"; + id: string; + type: "person" | "bot"; + name?: string; + avatar_url?: string; + person?: { + email?: string; + }; + bot?: { + owner: { + type: "workspace" | "user"; + }; + workspace_name?: string; + }; +} + +export interface NotionOptions extends ProviderOptions {} + +export const notion = (options: NotionOptions) => { + const tokenEndpoint = "https://api.notion.com/v1/oauth/token"; + return { + id: "notion", + name: "Notion", + createAuthorizationURL({ state, scopes, loginHint, redirectURI }) { + const _scopes: string[] = options.disableDefaultScope ? [] : []; + options.scope && _scopes.push(...options.scope); + scopes && _scopes.push(...scopes); + return createAuthorizationURL({ + id: "notion", + options, + authorizationEndpoint: "https://api.notion.com/v1/oauth/authorize", + scopes: _scopes, + state, + redirectURI, + loginHint, + additionalParams: { + owner: "user", + }, + }); + }, + validateAuthorizationCode: async ({ code, redirectURI }) => { + return validateAuthorizationCode({ + code, + redirectURI, + options, + tokenEndpoint, + }); + }, + refreshAccessToken: options.refreshAccessToken + ? options.refreshAccessToken + : async (refreshToken) => { + return refreshAccessToken({ + refreshToken, + options: { + clientId: options.clientId, + clientKey: options.clientKey, + clientSecret: options.clientSecret, + }, + tokenEndpoint, + }); + }, + async getUserInfo(token) { + if (options.getUserInfo) { + return options.getUserInfo(token); + } + const { data: profile, error } = await betterFetch( + "https://api.notion.com/v1/users/me", + { + headers: { + Authorization: `Bearer ${token.accessToken}`, + "Notion-Version": "2022-06-28", + }, + }, + ); + if (error) { + return null; + } + + const userMap = await options.mapProfileToUser?.(profile); + return { + user: { + id: profile.id, + name: profile.name || "Notion User", + email: profile.person?.email || null, + image: profile.avatar_url, + emailVerified: !!profile.person?.email, + ...userMap, + }, + data: profile, + }; + }, + options, + } satisfies OAuthProvider; +};