Skip to content

Commit

Permalink
fix(widget): save jwt to localstorage to bypass 3rd party cookie limi…
Browse files Browse the repository at this point in the history
…tation
  • Loading branch information
devrsi0n committed Apr 24, 2024
1 parent ab57f75 commit ad4383a
Show file tree
Hide file tree
Showing 18 changed files with 155 additions and 62 deletions.
13 changes: 12 additions & 1 deletion apps/main/src/pages/api/auth/[...nextauth].ts
Original file line number Diff line number Diff line change
@@ -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);
}
10 changes: 9 additions & 1 deletion packages/trpc/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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'
3 changes: 1 addition & 2 deletions packages/trpc/src/auth/auth-options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
6 changes: 4 additions & 2 deletions packages/trpc/src/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
**/
Expand All @@ -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({
Expand Down
1 change: 1 addition & 0 deletions packages/trpc/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
35 changes: 35 additions & 0 deletions packages/trpc/src/session.ts
Original file line number Diff line number Diff line change
@@ -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<Session | null> {
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;
}
2 changes: 1 addition & 1 deletion packages/trpc/typings/next-auth.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ declare module 'next-auth' {
* used to authenticate the user in Safari via http header "Authorization: Bearer <token>",
* because Safari doesn't allow 3rd party cookies
*/
jwtToken: string;
jwt: string;
user: {
id: string;
name: string;
Expand Down
3 changes: 2 additions & 1 deletion packages/ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ export function PredefinedCurrentUser(
name: 'Michael',
image: '/images/avatars/male-2.jpeg',
},
jwtToken: '',
jwt: '',
refetchUser: asyncNoop as any,
}),
);
Expand Down
4 changes: 2 additions & 2 deletions packages/ui/src/blocks/user-menu/user-menu.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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);
}}
>
<IconLogOut size={14} />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
Expand Down
Original file line number Diff line number Diff line change
@@ -1,19 +1,23 @@
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,
EMPTY_CURRENT_USER_CONTEXT,
} 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';
Expand All @@ -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 (
<CurrentUserContext.Provider value={value}>
Expand Down
49 changes: 19 additions & 30 deletions packages/ui/src/hooks/use-sign-in-window.ts
Original file line number Diff line number Diff line change
@@ -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<typeof SignInSuccessSchema>;

export function useSignInWindow({
width = 480,
height = 760,
}: useSignInWindowOptions = {}): () => void {
const popupWindow = React.useRef<Window | null>(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;
}
Expand Down
7 changes: 6 additions & 1 deletion packages/ui/src/pages/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
Expand Down Expand Up @@ -42,7 +45,9 @@ export const App = trpc.withTRPC(function App({
: 'chirpy.theme'
}
>
<CurrentUserProvider>
<CurrentUserProvider
isWidget={!!(pageProps as CommonWidgetProps).isWidget}
>
<ToastProvider>
<NotificationProvider>
<Component {...pageProps} />
Expand Down
18 changes: 4 additions & 14 deletions packages/ui/src/pages/auth/redirecting.tsx
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -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)) {
Expand All @@ -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(
Expand Down
Loading

0 comments on commit ad4383a

Please sign in to comment.