diff --git a/apps/main/src/pages/api/auth/[...nextauth].ts b/apps/main/src/pages/api/auth/[...nextauth].ts index 0ed51042f..35dc8837b 100644 --- a/apps/main/src/pages/api/auth/[...nextauth].ts +++ b/apps/main/src/pages/api/auth/[...nextauth].ts @@ -1,8 +1,19 @@ -import { NextAuth } from '@chirpy-dev/trpc'; +import { getWidgetSession, isWidgetRequest, NextAuth } from '@chirpy-dev/trpc'; import { getNextAuthOptions } from '@chirpy-dev/trpc/src/auth/auth-options'; import type { NextApiRequest, NextApiResponse } from 'next'; export default async function auth(req: NextApiRequest, res: NextApiResponse) { + const isWidget = isWidgetRequest(req); + if ( + isWidget && + req.url?.endsWith('/api/auth/session') && + req.method === 'GET' + ) { + // Used by widget/iframe, nextauth can't handle auth via http header + const session = await getWidgetSession(req); + return res.status(200).json(session || {}); + } + const options = getNextAuthOptions(req); return await NextAuth(options)(req, res); } diff --git a/packages/trpc/docker-compose.yml b/packages/trpc/docker-compose.yml index 2b1790421..a23137cff 100644 --- a/packages/trpc/docker-compose.yml +++ b/packages/trpc/docker-compose.yml @@ -6,8 +6,16 @@ services: ports: - 5432:5432 volumes: - - ./postgres:/var/lib/postgresql/data + - postgres_data:/var/lib/postgresql/data environment: - POSTGRES_PASSWORD=postgres - POSTGRES_USER=postgres - POSTGRES_DB=postgres + +volumes: + postgres_data: + driver: local + driver_opts: + type: 'none' + device: './postgres' + o: 'bind' diff --git a/packages/trpc/src/auth/auth-options.ts b/packages/trpc/src/auth/auth-options.ts index 5273d838a..98d79d7c7 100644 --- a/packages/trpc/src/auth/auth-options.ts +++ b/packages/trpc/src/auth/auth-options.ts @@ -84,9 +84,8 @@ export function getNextAuthOptions( plan: userData.plan || 'HOBBY', editableProjectIds, }; - session.jwtToken = await getToken({ + session.jwt = await getToken({ req, - secret: process.env.NEXTAUTH_SECRET, raw: true, }); return session; diff --git a/packages/trpc/src/context.ts b/packages/trpc/src/context.ts index c974d3caf..16e8f8091 100644 --- a/packages/trpc/src/context.ts +++ b/packages/trpc/src/context.ts @@ -4,13 +4,14 @@ import { type Session } from 'next-auth'; import { prisma } from './common/db-client'; import { getServerAuthSession } from './common/get-server-auth-session'; +import { getWidgetSession } from './session'; type CreateContextOptions = { session: Session | null; }; /** Use this helper for: - * - testing, so we dont have to mock Next.js' req/res + * - testing, so we don't have to mock Next.js' req/res * - trpc's `createSSGHelpers` where we don't have req/res * @see https://create.t3.gg/en/usage/trpc#-servertrpccontextts **/ @@ -29,7 +30,8 @@ export const createContext = async (opts: CreateNextContextOptions) => { const { req, res } = opts; // Get the session from the server using the getServerSession wrapper function - const session = await getServerAuthSession({ req, res }); + const session = + (await getWidgetSession(req)) || (await getServerAuthSession({ req, res })); return { ...createContextInner({ diff --git a/packages/trpc/src/index.ts b/packages/trpc/src/index.ts index beb3f994b..49c15c356 100644 --- a/packages/trpc/src/index.ts +++ b/packages/trpc/src/index.ts @@ -4,6 +4,7 @@ export * from './trpc-server'; export { createNextApiHandler } from '@trpc/server/adapters/next'; export * from './router'; export { ssg } from './ssg'; +export * from './session'; export * from './common/db-client'; export * from './common/revalidate'; diff --git a/packages/trpc/src/session.ts b/packages/trpc/src/session.ts new file mode 100644 index 000000000..26e1e8c1d --- /dev/null +++ b/packages/trpc/src/session.ts @@ -0,0 +1,35 @@ +import { WIDGET_HEADER } from '@chirpy-dev/utils'; +import type { NextApiRequest } from 'next'; +import type { Session } from 'next-auth'; +import { getToken } from 'next-auth/jwt'; + +import { getNextAuthOptions } from './auth/auth-options'; + +export function isWidgetRequest(req: NextApiRequest) { + return req.headers[WIDGET_HEADER.toLowerCase()] === 'true'; +} + +/** + * Used by widget/iframe, nextauth can't handle auth via http header + */ +export async function getWidgetSession( + req: NextApiRequest, +): Promise { + if (!isWidgetRequest(req)) { + return null; + } + const options = getNextAuthOptions(req); + // getToken supports cookie and http header `authorization` + const token = await getToken({ + req, + }); + if (!token) { + return null; + } + const session = await options.callbacks?.session?.({ + token, + // @ts-expect-error + session: {}, + }); + return session as Session; +} diff --git a/packages/trpc/typings/next-auth.d.ts b/packages/trpc/typings/next-auth.d.ts index 5ba84f7c6..3814b92b0 100644 --- a/packages/trpc/typings/next-auth.d.ts +++ b/packages/trpc/typings/next-auth.d.ts @@ -10,7 +10,7 @@ declare module 'next-auth' { * used to authenticate the user in Safari via http header "Authorization: Bearer ", * because Safari doesn't allow 3rd party cookies */ - jwtToken: string; + jwt: string; user: { id: string; name: string; diff --git a/packages/ui/package.json b/packages/ui/package.json index 8891ee4bf..21cd02e4b 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -56,7 +56,8 @@ "react-error-boundary": "4.0.11", "super-tiny-icons": "0.5.0", "superjson": "1.13.1", - "tailwindcss-animate": "1.0.6" + "tailwindcss-animate": "1.0.6", + "zod": "3.22.4" }, "devDependencies": { "@babel/core": "7.22.10", diff --git a/packages/ui/src/blocks/comment-widget-preview/predefined-current-user.tsx b/packages/ui/src/blocks/comment-widget-preview/predefined-current-user.tsx index de4926e3d..79d6ea818 100644 --- a/packages/ui/src/blocks/comment-widget-preview/predefined-current-user.tsx +++ b/packages/ui/src/blocks/comment-widget-preview/predefined-current-user.tsx @@ -29,7 +29,7 @@ export function PredefinedCurrentUser( name: 'Michael', image: '/images/avatars/male-2.jpeg', }, - jwtToken: '', + jwt: '', refetchUser: asyncNoop as any, }), ); diff --git a/packages/ui/src/blocks/user-menu/user-menu.tsx b/packages/ui/src/blocks/user-menu/user-menu.tsx index 7dca7995d..ef467131a 100644 --- a/packages/ui/src/blocks/user-menu/user-menu.tsx +++ b/packages/ui/src/blocks/user-menu/user-menu.tsx @@ -1,4 +1,4 @@ -import { SIGN_IN_SUCCESS_KEY, SUPPORT_LINK } from '@chirpy-dev/utils'; +import { SUPPORT_LINK, TOKEN_KEY } from '@chirpy-dev/utils'; import clsx from 'clsx'; import { signOut } from 'next-auth/react'; import * as React from 'react'; @@ -130,10 +130,10 @@ export function UserMenu(props: UserMenuProps): JSX.Element { disabled={!!process.env.NEXT_PUBLIC_MAINTENANCE_MODE} className={itemStyle} onClick={async () => { + localStorage.removeItem(TOKEN_KEY); await signOut({ redirect: !isWidget, }); - localStorage.removeItem(SIGN_IN_SUCCESS_KEY); }} > diff --git a/packages/ui/src/contexts/current-user-context/current-user-context.ts b/packages/ui/src/contexts/current-user-context/current-user-context.ts index d9975d3b3..f9bd230cf 100644 --- a/packages/ui/src/contexts/current-user-context/current-user-context.ts +++ b/packages/ui/src/contexts/current-user-context/current-user-context.ts @@ -16,13 +16,11 @@ export type CurrentUserContextType = { isPreview?: true; isPaid?: boolean; data: UserData; - jwtToken: string; }; export const EMPTY_CURRENT_USER_CONTEXT: CurrentUserContextType = { isSignIn: false, data: {}, - jwtToken: '', loading: false, refetchUser: asyncNoop as unknown as RefetchUser, }; diff --git a/packages/ui/src/contexts/current-user-context/current-user-provider.tsx b/packages/ui/src/contexts/current-user-context/current-user-provider.tsx index 453fde1d7..73e0d8075 100644 --- a/packages/ui/src/contexts/current-user-context/current-user-provider.tsx +++ b/packages/ui/src/contexts/current-user-context/current-user-provider.tsx @@ -1,7 +1,9 @@ +import { TOKEN_KEY } from '@chirpy-dev/utils'; import { useSession } from 'next-auth/react'; import * as React from 'react'; import { useHasMounted } from '../../hooks/use-has-mounted'; +import type { SignInSuccess } from '../../hooks/use-sign-in-window'; import { CurrentUserContext, CurrentUserContextType, @@ -9,11 +11,13 @@ import { } from './current-user-context'; export type CurrentUserProviderProps = { + isWidget: boolean; children: React.ReactNode; }; export function CurrentUserProvider({ children, + isWidget, }: CurrentUserProviderProps): JSX.Element { const { data: session, status: sessionStatus, update } = useSession(); const sessionIsLoading = sessionStatus === 'loading'; @@ -32,13 +36,26 @@ export function CurrentUserProvider({ : {}; return { data, - jwtToken: session?.jwtToken || '', loading: sessionIsLoading, isSignIn: !!data.id, refetchUser: update, isPaid: ['PRO', 'ENTERPRISE'].includes(data?.plan || ''), }; - }, [hasMounted, session?.user, session?.jwtToken, sessionIsLoading, update]); + }, [hasMounted, session?.user, sessionIsLoading, update]); + + React.useEffect(() => { + if (session?.jwt) { + if (isWidget) { + // Refresh token + localStorage.setItem(TOKEN_KEY, session.jwt); + } else { + window.opener?.postMessage( + { type: 'sign-in-success', jwt: session.jwt } satisfies SignInSuccess, + '*', + ); + } + } + }, [session?.jwt, isWidget]); return ( diff --git a/packages/ui/src/hooks/use-sign-in-window.ts b/packages/ui/src/hooks/use-sign-in-window.ts index ae453d65c..0b8b99216 100644 --- a/packages/ui/src/hooks/use-sign-in-window.ts +++ b/packages/ui/src/hooks/use-sign-in-window.ts @@ -1,54 +1,43 @@ -import { SIGN_IN_SUCCESS_KEY } from '@chirpy-dev/utils'; -import { getSession } from 'next-auth/react'; +import { trpc } from '@chirpy-dev/trpc/src/client'; +import { TOKEN_KEY } from '@chirpy-dev/utils'; import * as React from 'react'; - -import { useEventListener } from './use-event-listener'; -import { useInterval } from './use-time'; +import { z } from 'zod'; export type useSignInWindowOptions = { width?: number; height?: number; }; +export const SignInSuccessSchema = z.object({ + type: z.literal('sign-in-success'), + jwt: z.string().min(10), +}); + +export type SignInSuccess = z.infer; + export function useSignInWindow({ width = 480, height = 760, }: useSignInWindowOptions = {}): () => void { const popupWindow = React.useRef(null); - const [isSigningIn, setIsSigningIn] = React.useState(false); + const utils = trpc.useContext(); + const handleClickSignIn = () => { - localStorage.removeItem(SIGN_IN_SUCCESS_KEY); popupWindow.current = popupCenterWindow( '/auth/sign-in?allowAnonymous=true', '_blank', width, height, ); - setIsSigningIn(true); - }; - - useEventListener('storage', async (event) => { - if (event.key === SIGN_IN_SUCCESS_KEY && event.newValue === 'true') { - setIsSigningIn(false); - popupWindow.current?.close(); - popupWindow.current = null; - // Force to refresh session - await getSession(); - } - }); - - useInterval( - () => { - if (localStorage.getItem(SIGN_IN_SUCCESS_KEY) === 'true') { - setIsSigningIn(false); + window.addEventListener('message', (e) => { + const result = SignInSuccessSchema.safeParse(e.data); + if (result.success) { + localStorage.setItem(TOKEN_KEY, result.data.jwt); popupWindow.current?.close(); - popupWindow.current = null; - // Force to refresh session - void getSession(); + utils.invalidate(); } - }, - isSigningIn ? 3000 : null, - ); + }); + }; return handleClickSignIn; } diff --git a/packages/ui/src/pages/app.tsx b/packages/ui/src/pages/app.tsx index 39ac3251e..a70f12e81 100644 --- a/packages/ui/src/pages/app.tsx +++ b/packages/ui/src/pages/app.tsx @@ -10,6 +10,9 @@ import * as React from 'react'; import { ToastProvider } from '../components'; import { CurrentUserProvider, NotificationProvider } from '../contexts'; +import { setupWidgetSessionHeader } from './widget-header'; + +setupWidgetSessionHeader(); const inter = Inter({ subsets: ['latin'], @@ -42,7 +45,9 @@ export const App = trpc.withTRPC(function App({ : 'chirpy.theme' } > - + diff --git a/packages/ui/src/pages/auth/redirecting.tsx b/packages/ui/src/pages/auth/redirecting.tsx index ab8525b10..deddf9b90 100644 --- a/packages/ui/src/pages/auth/redirecting.tsx +++ b/packages/ui/src/pages/auth/redirecting.tsx @@ -1,8 +1,4 @@ -import { - CALLBACK_URL_KEY, - SIGN_IN_SUCCESS_KEY, - TOKEN_KEY, -} from '@chirpy-dev/utils'; +import { CALLBACK_URL_KEY } from '@chirpy-dev/utils'; import { useRouter } from 'next/router'; import * as React from 'react'; @@ -13,16 +9,10 @@ import { useTimeout } from '../../hooks'; import { hasValidUserProfile } from '../../utilities'; export function Redirecting(): JSX.Element { - const { data, jwtToken, loading } = useCurrentUser(); + const { data } = useCurrentUser(); const router = useRouter(); React.useEffect(() => { - if (jwtToken) { - localStorage.setItem(TOKEN_KEY, jwtToken); - } - if (data.id) { - localStorage.setItem(SIGN_IN_SUCCESS_KEY, 'true'); - } - if (loading) { + if (!data.id) { return; } if (!hasValidUserProfile(data)) { @@ -32,7 +22,7 @@ export function Redirecting(): JSX.Element { sessionStorage.removeItem(CALLBACK_URL_KEY); router.push(callbackUrl || `/dashboard/${data.username}`); } - }, [jwtToken, router, data, loading]); + }, [router, data]); useTimeout(() => { if (!data.id) { router.push( diff --git a/packages/ui/src/pages/widget-header.ts b/packages/ui/src/pages/widget-header.ts new file mode 100644 index 000000000..86b4c810e --- /dev/null +++ b/packages/ui/src/pages/widget-header.ts @@ -0,0 +1,34 @@ +import { isSSRMode, TOKEN_KEY, WIDGET_HEADER } from '@chirpy-dev/utils'; + +export function setupWidgetSessionHeader() { + if (isSSRMode) { + return; + } + const originFetch = window.fetch; + window.fetch = async ( + input: RequestInfo | URL, + init?: RequestInit | undefined, + ) => { + const isWidget = location.pathname.startsWith('/widget/'); + if ( + isWidget && + typeof input === 'string' && + input.endsWith('/api/auth/session') + ) { + const token = localStorage.getItem(TOKEN_KEY); + if (token) { + init = { + ...init, + headers: { + ...init?.headers, + // Widgets/iframe can't access cookies, use saved token instead + Authorization: `Bearer ${token}`, + [WIDGET_HEADER]: 'true', + }, + }; + } + } + + return await originFetch(input, init); + }; +} diff --git a/packages/utils/src/constants.ts b/packages/utils/src/constants.ts index a06631a8a..460b84609 100644 --- a/packages/utils/src/constants.ts +++ b/packages/utils/src/constants.ts @@ -1,6 +1,6 @@ -export const SIGN_IN_SUCCESS_KEY = 'sign-in.success'; export const CALLBACK_URL_KEY = 'callback.url'; export const TOKEN_KEY = 'jwt.token'; +export const WIDGET_HEADER = 'X-Widget'; // 30 days export const SESSION_MAX_AGE = 30 * 24 * 60 * 60; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 076e0d2d1..dccc59e60 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -886,6 +886,9 @@ importers: tailwindcss-animate: specifier: 1.0.6 version: 1.0.6(tailwindcss@3.3.5) + zod: + specifier: 3.22.4 + version: 3.22.4 devDependencies: '@babel/core': specifier: 7.22.10 @@ -18348,7 +18351,7 @@ packages: '@panva/hkdf': 1.1.1 cookie: 0.5.0 jose: 4.15.4 - next: 14.1.1(@babel/core@7.12.9)(react-dom@18.2.0)(react@18.2.0) + next: 14.1.1(@babel/core@7.22.10)(react-dom@18.2.0)(react@18.2.0)(sass@1.69.5) oauth: 0.9.15 openid-client: 5.6.1 preact: 10.19.2