├── .cargo-ok ├── .gitignore ├── .prettierrc ├── README.md ├── example ├── .gitignore ├── README.md ├── components │ ├── AuthenticatedLayout.tsx │ ├── Avatar.tsx │ ├── DynamicLayout.tsx │ ├── Posts.tsx │ ├── Session.tsx │ ├── Spinner.tsx │ └── UnauthenticatedLayout.tsx ├── lib │ ├── api │ │ ├── auth.ts │ │ ├── comments.ts │ │ ├── posts.ts │ │ ├── sessions.ts │ │ └── utils.ts │ ├── auth.tsx │ ├── supabase.ts │ └── types.ts ├── next-env.d.ts ├── next.config.js ├── package-lock.json ├── package.json ├── pages │ ├── _app.tsx │ ├── _authenticated │ │ ├── account.tsx │ │ ├── index.tsx │ │ ├── posts │ │ │ └── [id].tsx │ │ ├── signin.tsx │ │ ├── signup.tsx │ │ └── ssr.tsx │ ├── _document.tsx │ ├── _middleware.ts │ ├── account │ │ ├── _middleware.ts │ │ └── index.tsx │ ├── index.tsx │ ├── posts │ │ └── [id].tsx │ ├── signin.tsx │ ├── signup.tsx │ └── ssr.tsx ├── postcss.config.js ├── prettier.config.js ├── public │ └── favicon.ico ├── styles │ └── globals.css ├── tailwind.config.js ├── tsconfig.json └── utils │ └── generic.ts ├── jestconfig.json ├── package-lock.json ├── package.json ├── schema.sql ├── src ├── cors.ts ├── handler.ts ├── index.ts ├── sessions.ts ├── types.ts └── websocket.ts ├── test └── handler.test.ts ├── tsconfig.json ├── webpack.config.js └── wrangler.toml /.cargo-ok: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alaister/supabase-cookie-auth-proxy/8e1ad711a20d4c988e93a2e0e925abe7f8bb3fb8/.cargo-ok -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | dist 2 | node_modules 3 | transpiled 4 | /.idea/ 5 | .vercel -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "semi": false, 4 | "trailingComma": "all", 5 | "tabWidth": 2, 6 | "printWidth": 80 7 | } 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Supabase Cookie Auth Proxy 2 | 3 | Utilizing Cloudflare workers to use cookie based session auth, instead of refreshing JWTs. 4 | 5 | [Watch a video explanation](https://www.youtube.com/watch?v=Y1bGFANu3DI) 6 | 7 | ### :warning: This is a work in progress 8 | 9 | ## How It Works 10 | 11 | #### Cloudflare Worker 12 | 13 | Supabase Cookie Auth Proxy is a Cloudflare worker that proxies all requests between supabase and your frontend. 14 | 15 | It intercepts key requests, such as on the sign in endpoint, and handles updating a session cookie. 16 | 17 | This session cookie id references a session stored on the edge in Workers KV. The session contains the user information and a custom JWT, which is added to each request to Supabase. 18 | 19 | A couple of extra endpoints are also added to control this new session functionality. GET `/edge/v1/session` for reading the current session with low latency, and DELETE `/edge/v1/sessions/*session-id*` for invalidating sessions. 20 | 21 | Websockets are also proxied with the correct JWT added, supporting Supabase realtime. 22 | 23 | #### Next.js Edge Auth 24 | 25 | The example provided is of Next.js and uses its `_middleware.ts` files in order to authorize users. 26 | 27 | To allow for SSG, but without having a flash on the first load of the user being logged out, every page is mirrored in the `_authenticated` directory. Then, the middleware checks on every request if the user is logged in or not. If they are, the request gets rewritten to the corresponding page, which has the authenticated layout. If they are not logged in, the second middleware (in the non \_authenticated page directory) will be hit, as the initial middleware did not rewrite the request. Upon being hit, the second middleware will redirect the user to the sign in screen. 28 | 29 | SSR may also be used to avoid all loading flashes. See `getServerSideProps` in [\_authenticated/ssr.tsx](example/pages/_authenticated/ssr.tsx) 30 | 31 | ## Installation 32 | 33 | Clone the git repo 34 | 35 | ```bash 36 | git clone https://github.com/alaister/supabase-cookie-auth-proxy.git 37 | ``` 38 | 39 | Update `account_id` and `[vars]` in `wrangler.toml` 40 | 41 | Run 42 | 43 | ```bash 44 | wrangler secret put SUPABASE_SERVICE_KEY 45 | wrangler secret put JWT_SECRET 46 | wrangler kv:namespace create SESSIONS_KV 47 | ``` 48 | 49 | and then update the `kv_namespaces` binding id in `wrangler.toml` 50 | 51 | Run 52 | 53 | ```bash 54 | wrangler publish 55 | ``` 56 | 57 | Setup the worker to run on `/supabase/*` on your domain 58 | 59 | Run the sql found in [schema.sql](schema.sql) in Supabase 60 | 61 | Change the supabase url found in your frontend project to `https://yourdomain.com/supabase` 62 | 63 | ## Todo 64 | 65 | - Use supabase hooks to call the edge function whenever the user is updated 66 | - Make oauth work. Gotrue (server) doesn't like when you mess with the redirect_uri. Maybe this will be easier once this is implemented: https://github.com/supabase/gotrue/issues/233 67 | - Work out how to use realtime in development. Currently Next.js rewrites don't seem to support websockets 68 | - Example for retrieving the user in Next.js api endpoints 69 | -------------------------------------------------------------------------------- /example/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | 18 | # misc 19 | .DS_Store 20 | *.pem 21 | 22 | # debug 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | 27 | # local env files 28 | .env.local 29 | .env.development.local 30 | .env.test.local 31 | .env.production.local 32 | 33 | # vercel 34 | .vercel 35 | 36 | # typescript 37 | *.tsbuildinfo 38 | -------------------------------------------------------------------------------- /example/README.md: -------------------------------------------------------------------------------- 1 | # Next.js + Tailwind CSS Example 2 | 3 | This example shows how to use [Tailwind CSS](https://tailwindcss.com/) [(v3.0)](https://tailwindcss.com/blog/tailwindcss-v3) with Next.js. It follows the steps outlined in the official [Tailwind docs](https://tailwindcss.com/docs/guides/nextjs). 4 | 5 | ## Preview 6 | 7 | Preview the example live on [StackBlitz](http://stackblitz.com/): 8 | 9 | [![Open in StackBlitz](https://developer.stackblitz.com/img/open_in_stackblitz.svg)](https://stackblitz.com/github/vercel/next.js/tree/canary/examples/with-tailwindcss) 10 | 11 | ## Deploy your own 12 | 13 | Deploy the example using [Vercel](https://vercel.com?utm_source=github&utm_medium=readme&utm_campaign=next-example): 14 | 15 | [![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/git/external?repository-url=https://github.com/vercel/next.js/tree/canary/examples/with-tailwindcss&project-name=with-tailwindcss&repository-name=with-tailwindcss) 16 | 17 | ## How to use 18 | 19 | Execute [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app) with [npm](https://docs.npmjs.com/cli/init) or [Yarn](https://yarnpkg.com/lang/en/docs/cli/create/) to bootstrap the example: 20 | 21 | ```bash 22 | npx create-next-app --example with-tailwindcss with-tailwindcss-app 23 | # or 24 | yarn create next-app --example with-tailwindcss with-tailwindcss-app 25 | ``` 26 | 27 | Deploy it to the cloud with [Vercel](https://vercel.com/new?utm_source=github&utm_medium=readme&utm_campaign=next-example) ([Documentation](https://nextjs.org/docs/deployment)). 28 | -------------------------------------------------------------------------------- /example/components/AuthenticatedLayout.tsx: -------------------------------------------------------------------------------- 1 | import Link from 'next/link' 2 | import { useRouter } from 'next/router' 3 | import { PropsWithChildren, useCallback, useEffect } from 'react' 4 | import { useSignOutMutation } from '../lib/api/auth' 5 | import { useAuth } from '../lib/auth' 6 | import Avatar from './Avatar' 7 | 8 | const AuthenticatedLayout = ({ children }: PropsWithChildren<{}>) => { 9 | const router = useRouter() 10 | const { user, isLoading } = useAuth() 11 | 12 | useEffect(() => { 13 | if (!isLoading && user === null) { 14 | router.push('/signin') 15 | } 16 | }, [isLoading, user]) 17 | 18 | const { mutate: signOut } = useSignOutMutation() 19 | const onSignOut = useCallback(() => { 20 | signOut() 21 | }, []) 22 | 23 | return ( 24 |
25 |
26 | 51 |
52 | 53 |
54 | {children} 55 |
56 | 57 | 67 |
68 | ) 69 | } 70 | 71 | export default AuthenticatedLayout 72 | -------------------------------------------------------------------------------- /example/components/Avatar.tsx: -------------------------------------------------------------------------------- 1 | type AvatarProps = { 2 | name?: string 3 | avatarUrl?: string 4 | } 5 | 6 | const Avatar = ({ name, avatarUrl }: AvatarProps) => { 7 | if (!avatarUrl) { 8 | return
9 | } 10 | 11 | return ( 12 | {`Photo 17 | ) 18 | } 19 | 20 | export default Avatar 21 | -------------------------------------------------------------------------------- /example/components/DynamicLayout.tsx: -------------------------------------------------------------------------------- 1 | import { PropsWithChildren } from 'react' 2 | import { useIsLoggedIn } from '../lib/auth' 3 | import AuthenticatedLayout from './AuthenticatedLayout' 4 | import UnauthenticatedLayout from './UnauthenticatedLayout' 5 | 6 | const DynamicLayout = ({ children }: PropsWithChildren<{}>) => { 7 | const isLoggedIn = useIsLoggedIn() 8 | 9 | return isLoggedIn ? ( 10 | {children} 11 | ) : ( 12 | {children} 13 | ) 14 | } 15 | 16 | export default DynamicLayout 17 | -------------------------------------------------------------------------------- /example/components/Posts.tsx: -------------------------------------------------------------------------------- 1 | import Link from 'next/link' 2 | import { usePostsQuery } from '../lib/api/posts' 3 | 4 | const Posts = () => { 5 | const { data, isLoading } = usePostsQuery() 6 | 7 | if (isLoading) { 8 | return ( 9 | 12 | ) 13 | } 14 | 15 | return ( 16 | 25 | ) 26 | } 27 | 28 | export default Posts 29 | -------------------------------------------------------------------------------- /example/components/Session.tsx: -------------------------------------------------------------------------------- 1 | import countryCodeEmoji from 'country-code-emoji' 2 | import { useCallback, useMemo } from 'react' 3 | import parser from 'ua-parser-js' 4 | import { 5 | Session as SessionType, 6 | useRevokeSessionMutation, 7 | } from '../lib/api/sessions' 8 | import { useAuth } from '../lib/auth' 9 | 10 | const Session = ({ session }: { session: SessionType }) => { 11 | const { sessionId } = useAuth() 12 | 13 | const createdAt = useMemo(() => { 14 | return new Date(session.created_at).toLocaleString(undefined, { 15 | dateStyle: 'long', 16 | timeStyle: 'long', 17 | }) 18 | }, [session]) 19 | const userAgent = useMemo(() => { 20 | if (!session.user_agent) { 21 | return null 22 | } 23 | 24 | return parser(session.user_agent) 25 | }, []) 26 | const isCurrentSession = session.id === sessionId 27 | 28 | const { mutate: revokeSession } = useRevokeSessionMutation() 29 | const onRevokeSession = useCallback(() => { 30 | revokeSession(session.id) 31 | }, []) 32 | 33 | return ( 34 |
35 |
36 |
37 | 38 | Signed in 39 | 40 |
41 | {session.country && ( 42 | 46 | {countryCodeEmoji(session.country)} 47 | 48 | )} 49 | 50 | {createdAt} 51 |
52 |
53 | 54 |
55 | 56 | Device 57 | 58 | {userAgent === null 59 | ? 'Unknown' 60 | : `${userAgent.browser.name} ${userAgent.browser.major} on ${userAgent.os.name}`} 61 |
62 | 63 | {session.ip && ( 64 |
65 | 66 | IP Address 67 | 68 | {session.ip} 69 |
70 | )} 71 |
72 | 73 | 79 |
80 | ) 81 | } 82 | 83 | export default Session 84 | -------------------------------------------------------------------------------- /example/components/Spinner.tsx: -------------------------------------------------------------------------------- 1 | // Stolen from @samselikoff 🙏 2 | 3 | const Spinner = () => { 4 | return ( 5 | 11 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 40 | 41 | 42 | ) 43 | } 44 | 45 | export default Spinner 46 | -------------------------------------------------------------------------------- /example/components/UnauthenticatedLayout.tsx: -------------------------------------------------------------------------------- 1 | import Link from 'next/link' 2 | import { PropsWithChildren } from 'react' 3 | 4 | const UnauthenticatedLayout = ({ children }: PropsWithChildren<{}>) => { 5 | return ( 6 |
7 |
8 | 25 |
26 | 27 |
28 | {children} 29 |
30 | 31 | 41 |
42 | ) 43 | } 44 | 45 | export default UnauthenticatedLayout 46 | -------------------------------------------------------------------------------- /example/lib/api/auth.ts: -------------------------------------------------------------------------------- 1 | import { PostgrestError, Session, User } from '@supabase/supabase-js' 2 | import { 3 | useMutation, 4 | useQuery, 5 | useQueryClient, 6 | UseQueryOptions, 7 | } from 'react-query' 8 | import supabase from '../supabase' 9 | import gravatarUrl from 'gravatar-url' 10 | import { UnauthenticatedError } from './utils' 11 | 12 | /* Current Session */ 13 | 14 | export async function getSession( 15 | signal?: AbortSignal 16 | ): Promise<{ session: { id: string; user: User } | null }> { 17 | const response = await fetch( 18 | `${process.env.NEXT_PUBLIC_SUPABASE_URL}/edge/v1/session`, 19 | { signal } 20 | ) 21 | 22 | if (response.status === 401) { 23 | throw new UnauthenticatedError() 24 | } 25 | 26 | if (response.status !== 200) { 27 | return { session: null } 28 | } 29 | 30 | const session = await response.json() 31 | 32 | return { session: { id: session.id, user: session.user as User } } 33 | } 34 | 35 | type SessionData = { session: { id: string; user: User } | null } 36 | type SessionError = PostgrestError 37 | 38 | export const useSessionQuery = ( 39 | options?: UseQueryOptions 40 | ) => 41 | useQuery( 42 | ['session'], 43 | ({ signal }) => getSession(signal), 44 | options 45 | ) 46 | 47 | /* Sign In */ 48 | 49 | type SignInData = { session: Session | null; user: User | null } 50 | type SignInVariables = { email: string; password: string } 51 | 52 | export async function signIn({ email, password }: SignInVariables) { 53 | const { error, session, user } = await supabase.auth.signIn({ 54 | email, 55 | password, 56 | }) 57 | 58 | if (error) { 59 | throw error 60 | } 61 | 62 | return { session, user } 63 | } 64 | 65 | export const useSignInMutation = () => { 66 | const queryClient = useQueryClient() 67 | 68 | return useMutation( 69 | ({ email, password }) => signIn({ email, password }), 70 | { 71 | async onSuccess() { 72 | await queryClient.resetQueries() 73 | }, 74 | } 75 | ) 76 | } 77 | 78 | /* Sign Up */ 79 | 80 | type SignUpData = { session: Session | null; user: User | null } 81 | type SignUpVariables = { name: string; email: string; password: string } 82 | 83 | export async function signUp({ name, email, password }: SignUpVariables) { 84 | const { error, session, user } = await supabase.auth.signUp( 85 | { 86 | email, 87 | password, 88 | }, 89 | { 90 | data: { 91 | full_name: name, 92 | avatar_url: gravatarUrl(email, { 93 | size: 512, 94 | rating: 'pg', 95 | default: 'mm', 96 | }), 97 | }, 98 | } 99 | ) 100 | 101 | if (error) { 102 | throw error 103 | } 104 | 105 | return { session, user } 106 | } 107 | 108 | export const useSignUpMutation = () => { 109 | const queryClient = useQueryClient() 110 | 111 | return useMutation( 112 | ({ name, email, password }) => signUp({ name, email, password }), 113 | { 114 | async onSuccess() { 115 | await queryClient.resetQueries() 116 | }, 117 | } 118 | ) 119 | } 120 | 121 | /* Forgot Password */ 122 | 123 | type ForgotPasswordData = { success: boolean } 124 | type ForgotPasswordVariables = { email: string } 125 | 126 | export async function forgotPassword({ email }: ForgotPasswordVariables) { 127 | const { success } = await fetch(`/api/auth/forgot-password`, { 128 | method: 'POST', 129 | body: JSON.stringify({ email }), 130 | headers: { 131 | 'Content-Type': 'application/json', 132 | }, 133 | }).then((res) => res.json()) 134 | 135 | return { success } 136 | } 137 | 138 | export const useForgotPasswordMutation = () => { 139 | return useMutation( 140 | ({ email }) => forgotPassword({ email }) 141 | ) 142 | } 143 | 144 | /* Sign Out */ 145 | 146 | type SignOutData = void 147 | type SignOutVariables = void 148 | 149 | export async function signOut() { 150 | const { error } = await supabase.auth.signOut() 151 | 152 | if (error) { 153 | throw error 154 | } 155 | } 156 | 157 | export const useSignOutMutation = () => { 158 | const queryClient = useQueryClient() 159 | 160 | return useMutation( 161 | () => signOut(), 162 | { 163 | async onSuccess() { 164 | await queryClient.resetQueries() 165 | }, 166 | } 167 | ) 168 | } 169 | -------------------------------------------------------------------------------- /example/lib/api/comments.ts: -------------------------------------------------------------------------------- 1 | import { PostgrestError } from '@supabase/supabase-js' 2 | import { 3 | useMutation, 4 | useQuery, 5 | useQueryClient, 6 | UseQueryOptions, 7 | } from 'react-query' 8 | import supabase from '../supabase' 9 | import { NotFoundError } from './utils' 10 | 11 | type Comment = { 12 | id: string 13 | created_at: string 14 | user_id: string 15 | post_id: string 16 | body: string 17 | author: { 18 | id: string 19 | created_at: string 20 | full_name?: string 21 | avatar_url?: string 22 | } 23 | } 24 | 25 | /* Get Comments */ 26 | 27 | export async function getCommentsForPost(postId: string, signal?: AbortSignal) { 28 | let query = supabase 29 | .from('comments') 30 | .select(`*,author:users(*)`) 31 | .eq('post_id', postId) 32 | .order('created_at', { ascending: true }) 33 | 34 | if (signal) { 35 | query = query.abortSignal(signal) 36 | } 37 | 38 | const { data, error } = await query 39 | 40 | if (error) { 41 | throw error 42 | } 43 | 44 | if (!data) { 45 | throw new NotFoundError('Comments not found') 46 | } 47 | 48 | return { comments: data } 49 | } 50 | 51 | type CommentsData = { comments: Comment[] } 52 | type CommentsError = PostgrestError 53 | 54 | export const useCommentsForPostQuery = ( 55 | postId: string, 56 | options?: UseQueryOptions 57 | ) => 58 | useQuery( 59 | ['comments', postId], 60 | ({ signal }) => getCommentsForPost(postId, signal), 61 | options 62 | ) 63 | 64 | /* Create Comment */ 65 | 66 | type CreateCommentData = { comment: Comment } 67 | type CreateCommentVariables = { postId: string; body: string } 68 | 69 | export async function createComment({ postId, body }: CreateCommentVariables) { 70 | const { data, error } = await supabase 71 | .from('comments') 72 | .insert({ 73 | post_id: postId, 74 | body, 75 | }) 76 | .select('*,author:users(*)') 77 | .single() 78 | 79 | if (error) { 80 | throw error 81 | } 82 | 83 | return { comment: data as Comment } 84 | } 85 | 86 | export const useCreateCommentMutation = () => { 87 | const queryClient = useQueryClient() 88 | 89 | return useMutation( 90 | ({ postId, body }) => createComment({ postId, body }), 91 | { 92 | async onSuccess({ comment }) { 93 | await queryClient.invalidateQueries(['comments', comment.post_id]) 94 | }, 95 | } 96 | ) 97 | } 98 | -------------------------------------------------------------------------------- /example/lib/api/posts.ts: -------------------------------------------------------------------------------- 1 | import { PostgrestError } from '@supabase/supabase-js' 2 | import { useQuery, UseQueryOptions } from 'react-query' 3 | import supabase from '../supabase' 4 | import { NotFoundError } from './utils' 5 | 6 | type Post = { 7 | id: string 8 | created_at: string 9 | title: string 10 | public: boolean 11 | } 12 | 13 | /* Get Posts */ 14 | 15 | export async function getPosts(signal?: AbortSignal) { 16 | let query = supabase.from('posts').select(`*`) 17 | 18 | if (signal) { 19 | query = query.abortSignal(signal) 20 | } 21 | 22 | const { data, error } = await query 23 | 24 | if (error) { 25 | throw error 26 | } 27 | 28 | if (!data) { 29 | throw new NotFoundError('Posts not found') 30 | } 31 | 32 | return { posts: data } 33 | } 34 | 35 | type PostsData = { posts: Post[] } 36 | type PostsError = PostgrestError 37 | 38 | export const usePostsQuery = ( 39 | options?: UseQueryOptions 40 | ) => 41 | useQuery( 42 | ['posts'], 43 | ({ signal }) => getPosts(signal), 44 | options 45 | ) 46 | 47 | /* Get Post */ 48 | 49 | export async function getPost(id: string, signal?: AbortSignal) { 50 | let query = supabase.from('posts').select(`*`).eq('id', id) 51 | 52 | if (signal) { 53 | query = query.abortSignal(signal) 54 | } 55 | 56 | const { data, error } = await query.maybeSingle() 57 | 58 | if (error) { 59 | throw error 60 | } 61 | 62 | if (!data) { 63 | throw new NotFoundError('Post not found') 64 | } 65 | 66 | return { post: data } 67 | } 68 | 69 | type PostData = { post: Post } 70 | type PostError = PostgrestError 71 | 72 | export const usePostQuery = ( 73 | id: string, 74 | options?: UseQueryOptions 75 | ) => 76 | useQuery( 77 | ['post', id], 78 | ({ signal }) => getPost(id, signal), 79 | options 80 | ) 81 | -------------------------------------------------------------------------------- /example/lib/api/sessions.ts: -------------------------------------------------------------------------------- 1 | import { PostgrestError } from '@supabase/supabase-js' 2 | import { 3 | useMutation, 4 | useQuery, 5 | useQueryClient, 6 | UseQueryOptions, 7 | } from 'react-query' 8 | import { useAuth } from '../auth' 9 | import supabase from '../supabase' 10 | import { NotFoundError } from './utils' 11 | 12 | export type Session = { 13 | id: string 14 | created_at: string 15 | expires_at: string 16 | user_id: string 17 | ip: string | null 18 | country: string | null 19 | user_agent: string | null 20 | } 21 | 22 | /* Get Sessions */ 23 | 24 | export async function getSessions(signal?: AbortSignal) { 25 | let query = supabase 26 | .from('sessions') 27 | .select(`*`) 28 | .order('created_at', { ascending: false }) 29 | 30 | if (signal) { 31 | query = query.abortSignal(signal) 32 | } 33 | 34 | const { data, error } = await query 35 | 36 | if (error) { 37 | throw error 38 | } 39 | 40 | if (!data) { 41 | throw new NotFoundError('Sessions not found') 42 | } 43 | 44 | return { sessions: data } 45 | } 46 | 47 | type SessionsData = { sessions: Session[] } 48 | type SessionsError = PostgrestError 49 | 50 | export const useSessionsQuery = ( 51 | options?: UseQueryOptions 52 | ) => 53 | useQuery( 54 | ['sessions'], 55 | ({ signal }) => getSessions(signal), 56 | options 57 | ) 58 | 59 | /* Revoke Session */ 60 | 61 | export async function revokeSession(id: string, signal?: AbortSignal) { 62 | await fetch( 63 | `${process.env.NEXT_PUBLIC_SUPABASE_URL}/edge/v1/sessions/${id}`, 64 | { 65 | method: 'DELETE', 66 | signal, 67 | } 68 | ) 69 | 70 | return id 71 | } 72 | 73 | type RevokeSessionData = string 74 | type RevokeSessionVariables = string 75 | 76 | export const useRevokeSessionMutation = () => { 77 | const queryClient = useQueryClient() 78 | const { sessionId } = useAuth() 79 | 80 | return useMutation( 81 | (id) => revokeSession(id), 82 | { 83 | async onSuccess(id) { 84 | if (sessionId === id) { 85 | // We're essentially signing out here 86 | await queryClient.resetQueries() 87 | } else { 88 | await queryClient.invalidateQueries(['sessions']) 89 | } 90 | }, 91 | } 92 | ) 93 | } 94 | -------------------------------------------------------------------------------- /example/lib/api/utils.ts: -------------------------------------------------------------------------------- 1 | export class NotFoundError extends Error {} 2 | export class UnauthenticatedError extends Error {} 3 | 4 | export const DEFAULT_PAGE_SIZE = 20 5 | 6 | export function getPagination(page?: number, size: number = DEFAULT_PAGE_SIZE) { 7 | const limit = size 8 | const from = page ? page * limit : 0 9 | const to = page ? from + size - 1 : size - 1 10 | 11 | return { from, to } 12 | } 13 | -------------------------------------------------------------------------------- /example/lib/auth.tsx: -------------------------------------------------------------------------------- 1 | import { User } from '@supabase/supabase-js' 2 | import { createContext, PropsWithChildren, useContext, useMemo } from 'react' 3 | import { useSessionQuery } from './api/auth' 4 | 5 | type AuthContextProps = { 6 | sessionId: string | null 7 | user: User | null 8 | isLoading: boolean 9 | } 10 | 11 | export const AuthContext = createContext({ 12 | sessionId: null, 13 | user: null, 14 | isLoading: true, 15 | }) 16 | 17 | type AuthProviderProps = { 18 | initialSession?: { id: string; user: User } | null 19 | } 20 | 21 | export const AuthProvider = ({ 22 | children, 23 | initialSession = null, 24 | }: PropsWithChildren) => { 25 | const { data, isLoading } = useSessionQuery({ 26 | initialData: initialSession ? { session: initialSession } : undefined, 27 | }) 28 | 29 | const value = useMemo( 30 | () => ({ 31 | sessionId: data?.session?.id ?? null, 32 | user: data?.session?.user ?? null, 33 | isLoading, 34 | }), 35 | [isLoading] 36 | ) 37 | 38 | return {children} 39 | } 40 | 41 | export const useAuth = () => useContext(AuthContext) 42 | 43 | export const useUser = () => useAuth()?.user ?? null 44 | 45 | export const useIsLoggedIn = () => { 46 | const { isLoading, user } = useAuth() 47 | if (isLoading) { 48 | return null 49 | } 50 | 51 | return user !== null 52 | } 53 | -------------------------------------------------------------------------------- /example/lib/supabase.ts: -------------------------------------------------------------------------------- 1 | import { createClient } from '@supabase/supabase-js' 2 | 3 | if (!process.env.NEXT_PUBLIC_SUPABASE_URL) { 4 | throw new Error('Missing NEXT_PUBLIC_SUPABASE_URL environment variable') 5 | } 6 | 7 | if (!process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY) { 8 | throw new Error('Missing NEXT_PUBLIC_SUPABASE_ANON_KEY environment variable') 9 | } 10 | 11 | const supabase = createClient( 12 | process.env.NEXT_PUBLIC_SUPABASE_URL, 13 | process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY, 14 | { 15 | fetch: (...args) => fetch(...args), 16 | autoRefreshToken: false, 17 | persistSession: false, 18 | detectSessionInUrl: false, 19 | } 20 | ) 21 | 22 | // Set a dummy jwt so logout gets called 23 | supabase.auth.setAuth('1') 24 | 25 | export default supabase 26 | -------------------------------------------------------------------------------- /example/lib/types.ts: -------------------------------------------------------------------------------- 1 | import type { ReactElement, ReactNode } from 'react' 2 | import type { NextPage } from 'next' 3 | import type { AppProps } from 'next/app' 4 | 5 | export type NextPageWithLayout = NextPage & { 6 | getLayout?: (page: ReactElement) => ReactNode 7 | } 8 | 9 | export type AppPropsWithLayout = AppProps & { 10 | Component: NextPageWithLayout 11 | } 12 | -------------------------------------------------------------------------------- /example/next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | 4 | // NOTE: This file should not be edited 5 | // see https://nextjs.org/docs/basic-features/typescript for more information. 6 | -------------------------------------------------------------------------------- /example/next.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | module.exports = { 3 | reactStrictMode: true, 4 | async rewrites() { 5 | return { 6 | beforeFiles: [ 7 | { 8 | source: '/supabase/:path*', 9 | destination: 10 | 'https://supabase-cookie-auth-proxy.alaister.dev/supabase/:path*', 11 | }, 12 | ], 13 | } 14 | }, 15 | } 16 | -------------------------------------------------------------------------------- /example/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "scripts": { 4 | "dev": "next dev", 5 | "build": "next build", 6 | "start": "next start" 7 | }, 8 | "dependencies": { 9 | "@heroicons/react": "^1.0.6", 10 | "@supabase/supabase-js": "^1.30.7", 11 | "country-code-emoji": "^2.3.0", 12 | "gravatar-url": "^4.0.1", 13 | "next": "latest", 14 | "react": "^17.0.2", 15 | "react-dom": "^17.0.2", 16 | "react-query": "^3.34.16", 17 | "ua-parser-js": "^1.0.2" 18 | }, 19 | "devDependencies": { 20 | "@tailwindcss/forms": "^0.5.0", 21 | "@types/node": "^17.0.21", 22 | "@types/react": "^17.0.39", 23 | "@types/ua-parser-js": "^0.7.36", 24 | "autoprefixer": "^10.4.0", 25 | "postcss": "^8.4.5", 26 | "prettier": "^2.5.1", 27 | "prettier-plugin-tailwindcss": "^0.1.1", 28 | "tailwindcss": "^3.0.7", 29 | "tailwindcss-font-inter": "^3.0.1", 30 | "typescript": "^4.6.2" 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /example/pages/_app.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from 'react' 2 | import { Hydrate, QueryClient, QueryClientProvider } from 'react-query' 3 | import { NotFoundError, UnauthenticatedError } from '../lib/api/utils' 4 | import { AuthProvider } from '../lib/auth' 5 | import { AppPropsWithLayout } from '../lib/types' 6 | import '../styles/globals.css' 7 | 8 | const CustomApp = ({ Component, pageProps }: AppPropsWithLayout) => { 9 | const [queryClient] = useState( 10 | () => 11 | new QueryClient({ 12 | defaultOptions: { 13 | queries: { 14 | retry: (failureCount, error) => { 15 | // Don't retry on 404s 16 | if ( 17 | error instanceof NotFoundError || 18 | error instanceof UnauthenticatedError 19 | ) { 20 | return false 21 | } 22 | 23 | if (failureCount < 3) { 24 | return true 25 | } 26 | 27 | return false 28 | }, 29 | }, 30 | }, 31 | }) 32 | ) 33 | 34 | const getLayout = Component.getLayout ?? ((page) => page) 35 | 36 | return ( 37 | 38 | 39 | 40 | {getLayout()} 41 | 42 | 43 | 44 | ) 45 | } 46 | 47 | export default CustomApp 48 | -------------------------------------------------------------------------------- /example/pages/_authenticated/account.tsx: -------------------------------------------------------------------------------- 1 | import AccountPage from '../account' 2 | 3 | export default AccountPage 4 | -------------------------------------------------------------------------------- /example/pages/_authenticated/index.tsx: -------------------------------------------------------------------------------- 1 | import AuthenticatedLayout from '../../components/AuthenticatedLayout' 2 | import { NextPageWithLayout } from '../../lib/types' 3 | import IndexPage, { getStaticProps } from '../index' 4 | 5 | export { getStaticProps } 6 | 7 | const AuthenticatedIndexPage: NextPageWithLayout = (props) => { 8 | return 9 | } 10 | 11 | AuthenticatedIndexPage.getLayout = (page) => ( 12 | {page} 13 | ) 14 | 15 | export default AuthenticatedIndexPage 16 | -------------------------------------------------------------------------------- /example/pages/_authenticated/posts/[id].tsx: -------------------------------------------------------------------------------- 1 | import AuthenticatedLayout from '../../../components/AuthenticatedLayout' 2 | import { NextPageWithLayout } from '../../../lib/types' 3 | import PostShowPage, { getStaticPaths, getStaticProps } from '../../posts/[id]' 4 | 5 | export { getStaticPaths, getStaticProps } 6 | 7 | const AuthenticatedPostShowPage: NextPageWithLayout = (props) => { 8 | return 9 | } 10 | 11 | AuthenticatedPostShowPage.getLayout = (page) => ( 12 | {page} 13 | ) 14 | 15 | export default AuthenticatedPostShowPage 16 | -------------------------------------------------------------------------------- /example/pages/_authenticated/signin.tsx: -------------------------------------------------------------------------------- 1 | import SignInPage from '../signin' 2 | 3 | export default SignInPage 4 | -------------------------------------------------------------------------------- /example/pages/_authenticated/signup.tsx: -------------------------------------------------------------------------------- 1 | import SignUpPage from '../signup' 2 | 3 | export default SignUpPage 4 | -------------------------------------------------------------------------------- /example/pages/_authenticated/ssr.tsx: -------------------------------------------------------------------------------- 1 | import { GetServerSideProps } from 'next' 2 | import AuthenticatedLayout from '../../components/AuthenticatedLayout' 3 | import { NextPageWithLayout } from '../../lib/types' 4 | import SSRPage from '../ssr' 5 | 6 | // We only need to run getServerSideProps in the _authenticated route, 7 | // because the user will be logged out if they make it to the normal one 8 | export const getServerSideProps: GetServerSideProps = async (context) => { 9 | const request = new Request( 10 | `${process.env.NEXT_PUBLIC_SUPABASE_URL}/edge/v1/session` 11 | ) 12 | 13 | // Forward cookie header 14 | const cookieHeader = context.req.headers.cookie 15 | if (cookieHeader) { 16 | request.headers.set('Cookie', cookieHeader) 17 | } 18 | 19 | const response = await fetch(request) 20 | 21 | if (response.status !== 200) { 22 | return { 23 | props: { 24 | initialSession: null, 25 | }, 26 | } 27 | } 28 | 29 | const session = await response.json() 30 | 31 | return { 32 | props: { 33 | initialSession: session, 34 | }, 35 | } 36 | } 37 | 38 | const AuthenticatedSSRPage: NextPageWithLayout = (props) => { 39 | return 40 | } 41 | 42 | AuthenticatedSSRPage.getLayout = (page) => ( 43 | {page} 44 | ) 45 | 46 | export default AuthenticatedSSRPage 47 | -------------------------------------------------------------------------------- /example/pages/_document.tsx: -------------------------------------------------------------------------------- 1 | import { Html, Head, Main, NextScript } from 'next/document' 2 | 3 | const CustomDocument = () => { 4 | return ( 5 | 6 | 7 | 8 |
9 | 10 | 11 | 12 | ) 13 | } 14 | 15 | export default CustomDocument 16 | -------------------------------------------------------------------------------- /example/pages/_middleware.ts: -------------------------------------------------------------------------------- 1 | import { NextRequest, NextResponse } from 'next/server' 2 | 3 | export default async function middleware(req: NextRequest) { 4 | const url = req.nextUrl.clone() 5 | const { pathname } = req.nextUrl 6 | 7 | if (pathname.startsWith(`/_authenticated`)) { 8 | return new Response(null, { status: 404 }) 9 | } 10 | 11 | const cookieHeader = req.headers.get('cookie') 12 | if (!cookieHeader) { 13 | return NextResponse.next() 14 | } 15 | 16 | const { status } = await fetch( 17 | `${process.env.NEXT_PUBLIC_SUPABASE_URL}/edge/v1/session`, 18 | { 19 | headers: { 20 | cookie: cookieHeader, 21 | }, 22 | } 23 | ) 24 | 25 | if (status === 200) { 26 | url.pathname = `/_authenticated${pathname}` 27 | return NextResponse.rewrite(url) 28 | } 29 | 30 | return NextResponse.next() 31 | } 32 | -------------------------------------------------------------------------------- /example/pages/account/_middleware.ts: -------------------------------------------------------------------------------- 1 | import { NextRequest, NextResponse } from 'next/server' 2 | 3 | export default async function middleware(req: NextRequest) { 4 | // If we're arriving here, it means that the first middleware didn't 5 | // rewrite the request to _authenticated, meaning we're not logged in. 6 | 7 | const url = req.nextUrl.clone() 8 | 9 | url.searchParams.set('next', url.pathname) 10 | url.pathname = '/signin' 11 | 12 | return NextResponse.redirect(url) 13 | } 14 | -------------------------------------------------------------------------------- /example/pages/account/index.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react' 2 | import { useQueryClient } from 'react-query' 3 | import AuthenticatedLayout from '../../components/AuthenticatedLayout' 4 | import Session from '../../components/Session' 5 | import Spinner from '../../components/Spinner' 6 | import { useSessionsQuery } from '../../lib/api/sessions' 7 | import { useIsLoggedIn } from '../../lib/auth' 8 | import supabase from '../../lib/supabase' 9 | import { NextPageWithLayout } from '../../lib/types' 10 | 11 | const AccountPage: NextPageWithLayout = () => { 12 | const queryClient = useQueryClient() 13 | const isLoggedIn = useIsLoggedIn() 14 | 15 | const { data, isLoading } = useSessionsQuery() 16 | 17 | useEffect(() => { 18 | const subscription = supabase 19 | .from(`sessions`) 20 | .on('*', (payload) => { 21 | queryClient.setQueriesData(['sessions'], (oldData: any) => { 22 | const update = (entity: any) => { 23 | if (payload.eventType === 'INSERT') { 24 | return { 25 | sessions: [payload.new, ...entity.sessions], 26 | } 27 | } 28 | 29 | if (payload.eventType === 'DELETE') { 30 | return { 31 | sessions: entity.sessions.filter( 32 | (session: any) => session.id !== payload.old.id 33 | ), 34 | } 35 | } 36 | 37 | return entity 38 | } 39 | 40 | return Array.isArray(oldData) ? oldData.map(update) : update(oldData) 41 | }) 42 | }) 43 | .subscribe() 44 | 45 | return () => { 46 | supabase.removeSubscription(subscription) 47 | } 48 | // Re-run on login changes 49 | }, [isLoggedIn]) 50 | 51 | return ( 52 |
53 |

Sessions

54 | 55 |
56 | {isLoading && ( 57 |
58 | 59 |
60 | )} 61 | {data?.sessions.map((session) => ( 62 | 63 | ))} 64 |
65 |
66 | ) 67 | } 68 | 69 | AccountPage.getLayout = (page) => ( 70 | {page} 71 | ) 72 | 73 | export default AccountPage 74 | -------------------------------------------------------------------------------- /example/pages/index.tsx: -------------------------------------------------------------------------------- 1 | import { GetStaticProps } from 'next' 2 | import Link from 'next/link' 3 | import { dehydrate, DehydratedState, QueryClient } from 'react-query' 4 | import DynamicLayout from '../components/DynamicLayout' 5 | import Posts from '../components/Posts' 6 | import { getPosts } from '../lib/api/posts' 7 | import { NextPageWithLayout } from '../lib/types' 8 | 9 | export const getStaticProps: GetStaticProps<{ 10 | dehydratedState: DehydratedState 11 | }> = async () => { 12 | const queryClient = new QueryClient() 13 | 14 | await queryClient.prefetchQuery(['posts'], ({ signal }) => getPosts(signal)) 15 | 16 | return { 17 | props: { dehydratedState: dehydrate(queryClient) }, 18 | } 19 | } 20 | 21 | const IndexPage: NextPageWithLayout = () => { 22 | return ( 23 |
24 |

Posts

25 | 26 | 27 | 28 |
29 | 30 |
31 | 32 | SSR Example Page 33 | 34 |
35 |
36 | ) 37 | } 38 | 39 | IndexPage.getLayout = (page) => {page} 40 | 41 | export default IndexPage 42 | -------------------------------------------------------------------------------- /example/pages/posts/[id].tsx: -------------------------------------------------------------------------------- 1 | import { GetStaticPaths, GetStaticProps } from 'next' 2 | import { useRouter } from 'next/router' 3 | import { FormEvent, useCallback, useEffect } from 'react' 4 | import { 5 | dehydrate, 6 | DehydratedState, 7 | QueryClient, 8 | useQueryClient, 9 | } from 'react-query' 10 | import Avatar from '../../components/Avatar' 11 | import DynamicLayout from '../../components/DynamicLayout' 12 | import { 13 | useCommentsForPostQuery, 14 | useCreateCommentMutation, 15 | } from '../../lib/api/comments' 16 | import { getPost, getPosts, usePostQuery } from '../../lib/api/posts' 17 | import { useIsLoggedIn } from '../../lib/auth' 18 | import supabase from '../../lib/supabase' 19 | import { NextPageWithLayout } from '../../lib/types' 20 | import { firstStr } from '../../utils/generic' 21 | 22 | export const getStaticPaths: GetStaticPaths = async () => { 23 | const { posts } = await getPosts() 24 | 25 | return { 26 | paths: posts.map(({ id }) => ({ params: { id } })), 27 | fallback: true, 28 | } 29 | } 30 | 31 | export const getStaticProps: GetStaticProps< 32 | { 33 | dehydratedState: DehydratedState 34 | }, 35 | { id: string } 36 | > = async ({ params }) => { 37 | const { id } = params! 38 | const queryClient = new QueryClient() 39 | 40 | await queryClient.prefetchQuery(['post', id], ({ signal }) => 41 | getPost(id, signal) 42 | ) 43 | 44 | return { 45 | props: { dehydratedState: dehydrate(queryClient) }, 46 | } 47 | } 48 | 49 | const PostShowPage: NextPageWithLayout = () => { 50 | const router = useRouter() 51 | const { id } = router.query 52 | const queryClient = useQueryClient() 53 | 54 | const { data, isLoading } = usePostQuery(firstStr(id)) 55 | const { data: commentsData, isLoading: isLoadingComments } = 56 | useCommentsForPostQuery(firstStr(id)) 57 | 58 | const isLoggedIn = useIsLoggedIn() 59 | useEffect(() => { 60 | const subscription = supabase 61 | .from(`comments:post_id=eq.${firstStr(id)}`) 62 | .on('*', (payload) => { 63 | queryClient.setQueriesData( 64 | ['comments', firstStr(id)], 65 | (entity: any) => { 66 | if (payload.eventType === 'INSERT') { 67 | // we don't have the author included with the comment, so we'll just tell 68 | // react-query to refetch the data 69 | queryClient.invalidateQueries(['comments', firstStr(id)]) 70 | return entity 71 | } 72 | 73 | if (payload.eventType === 'UPDATE') { 74 | return { 75 | comments: entity.comments.map((comment: any) => { 76 | if (comment.id === payload.old.id) { 77 | return { ...comment, ...payload.new } 78 | } 79 | 80 | return comment 81 | }), 82 | } 83 | } 84 | 85 | if (payload.eventType === 'DELETE') { 86 | return { 87 | comments: entity.comments.filter( 88 | (comment: any) => comment.id !== payload.old.id 89 | ), 90 | } 91 | } 92 | 93 | return entity 94 | } 95 | ) 96 | }) 97 | .subscribe() 98 | 99 | return () => { 100 | supabase.removeSubscription(subscription) 101 | } 102 | // Re-run on login changes 103 | }, [isLoggedIn]) 104 | 105 | const { mutate: createComment } = useCreateCommentMutation() 106 | 107 | const onCreateComment = useCallback( 108 | (e: FormEvent) => { 109 | e.preventDefault() 110 | 111 | const form = e.currentTarget 112 | 113 | const body = new FormData(form).get('body')?.toString() 114 | if (!body?.trim()) { 115 | return alert('Must have a body') 116 | } 117 | 118 | createComment( 119 | { 120 | postId: firstStr(id), 121 | body, 122 | }, 123 | { 124 | async onSuccess({ comment }) { 125 | await queryClient.invalidateQueries(['comments', comment.post_id]) 126 | 127 | form.reset() 128 | }, 129 | } 130 | ) 131 | }, 132 | [id, queryClient] 133 | ) 134 | 135 | if (!isLoading && !data) { 136 | return
Post not found
137 | } 138 | 139 | return ( 140 |
141 |

142 | {isLoading ? 'Loading...' : data?.post.title} 143 |

144 | 145 |
146 | 147 |
148 |

{`Comments${ 149 | commentsData?.comments ? ` (${commentsData.comments.length})` : '' 150 | }`}

151 | 152 |
    153 | {isLoadingComments ? ( 154 |
  • Loading...
  • 155 | ) : ( 156 | commentsData?.comments.map((comment) => ( 157 |
  • 158 |
    159 | {comment.body} 160 | 161 | 165 | 166 | {comment.author.full_name} 167 | 168 | 169 |
    170 | 171 | {new Date(comment.created_at).toLocaleString()} 172 |
  • 173 | )) 174 | )} 175 |
176 | 177 | {isLoggedIn && ( 178 |
179 | 186 | 192 |
193 | )} 194 |
195 |
196 | ) 197 | } 198 | 199 | PostShowPage.getLayout = (page) => {page} 200 | 201 | export default PostShowPage 202 | -------------------------------------------------------------------------------- /example/pages/signin.tsx: -------------------------------------------------------------------------------- 1 | import { LockClosedIcon } from '@heroicons/react/solid' 2 | import { useRouter } from 'next/router' 3 | import { FormEvent, useCallback } from 'react' 4 | import UnauthenticatedLayout from '../components/UnauthenticatedLayout' 5 | import { useSignInMutation } from '../lib/api/auth' 6 | import supabase from '../lib/supabase' 7 | import { NextPageWithLayout } from '../lib/types' 8 | 9 | const SignInPage: NextPageWithLayout = () => { 10 | const router = useRouter() 11 | const { mutate: signIn } = useSignInMutation() 12 | 13 | const onSubmit = useCallback( 14 | (e: FormEvent) => { 15 | e.preventDefault() 16 | 17 | const { email, password } = Object.fromEntries( 18 | new FormData(e.currentTarget) 19 | ) 20 | 21 | signIn( 22 | { email: email.toString(), password: password.toString() }, 23 | { 24 | onSuccess() { 25 | router.replace('/') 26 | }, 27 | } 28 | ) 29 | }, 30 | [router] 31 | ) 32 | 33 | const signInWithGitHub = useCallback(() => { 34 | supabase.auth.signIn({ 35 | provider: 'github', 36 | }) 37 | }, []) 38 | 39 | return ( 40 |
41 |
42 |
43 |

44 | Sign in to your account 45 |

46 |
47 | 48 | {/*
49 | 66 |
*/} 67 | 68 | {/*
69 | */} 79 | 80 |
81 |
82 |
83 | 86 | 95 |
96 |
97 | 100 | 109 |
110 |
111 | 112 |
113 | 121 |
122 | 123 |
124 | 136 |
137 |
138 |
139 |
140 | ) 141 | } 142 | 143 | SignInPage.getLayout = (page) => ( 144 | {page} 145 | ) 146 | 147 | export default SignInPage 148 | -------------------------------------------------------------------------------- /example/pages/signup.tsx: -------------------------------------------------------------------------------- 1 | import { LockClosedIcon } from '@heroicons/react/solid' 2 | import { useRouter } from 'next/router' 3 | import { FormEvent, useCallback } from 'react' 4 | import UnauthenticatedLayout from '../components/UnauthenticatedLayout' 5 | import { useSignUpMutation } from '../lib/api/auth' 6 | import supabase from '../lib/supabase' 7 | import { NextPageWithLayout } from '../lib/types' 8 | 9 | const SignUpPage: NextPageWithLayout = () => { 10 | const router = useRouter() 11 | const { mutate: signUp } = useSignUpMutation() 12 | 13 | const onSubmit = useCallback( 14 | (e: FormEvent) => { 15 | e.preventDefault() 16 | 17 | const { name, email, password } = Object.fromEntries( 18 | new FormData(e.currentTarget) 19 | ) 20 | 21 | signUp( 22 | { 23 | name: name.toString(), 24 | email: email.toString(), 25 | password: password.toString(), 26 | }, 27 | { 28 | onSuccess() { 29 | router.replace('/') 30 | }, 31 | } 32 | ) 33 | }, 34 | [router] 35 | ) 36 | 37 | return ( 38 |
39 |
40 |
41 |

42 | Sign up 43 |

44 |
45 | 46 |
47 |
48 |
49 | 52 | 61 |
62 |
63 | 66 | 75 |
76 |
77 | 80 | 89 |
90 |
91 | 92 |
93 | 105 |
106 |
107 |
108 |
109 | ) 110 | } 111 | 112 | SignUpPage.getLayout = (page) => ( 113 | {page} 114 | ) 115 | 116 | export default SignUpPage 117 | -------------------------------------------------------------------------------- /example/pages/ssr.tsx: -------------------------------------------------------------------------------- 1 | import DynamicLayout from '../components/DynamicLayout' 2 | import { useAuth } from '../lib/auth' 3 | import { NextPageWithLayout } from '../lib/types' 4 | 5 | const SSRPage: NextPageWithLayout = () => { 6 | const auth = useAuth() 7 | 8 | return ( 9 |
10 |

SSR'd

11 |
12 |         {JSON.stringify(auth, null, 2)}
13 |       
14 |
15 | ) 16 | } 17 | 18 | SSRPage.getLayout = (page) => {page} 19 | 20 | export default SSRPage 21 | -------------------------------------------------------------------------------- /example/postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /example/prettier.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | singleQuote: true, 3 | semi: false, 4 | } 5 | -------------------------------------------------------------------------------- /example/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alaister/supabase-cookie-auth-proxy/8e1ad711a20d4c988e93a2e0e925abe7f8bb3fb8/example/public/favicon.ico -------------------------------------------------------------------------------- /example/styles/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | -------------------------------------------------------------------------------- /example/tailwind.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | content: [ 3 | './pages/**/*.{js,ts,jsx,tsx}', 4 | './components/**/*.{js,ts,jsx,tsx}', 5 | ], 6 | theme: { 7 | extend: { 8 | colors: { 9 | text: '#212529', 10 | }, 11 | animation: { 12 | pulse: 'pulse 1.2s cubic-bezier(0.4, 0, 0.6, 1) infinite', 13 | }, 14 | }, 15 | }, 16 | plugins: [require('tailwindcss-font-inter'), require('@tailwindcss/forms')], 17 | } 18 | -------------------------------------------------------------------------------- /example/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "forceConsistentCasingInFileNames": true, 9 | "noEmit": true, 10 | "esModuleInterop": true, 11 | "module": "esnext", 12 | "moduleResolution": "node", 13 | "resolveJsonModule": true, 14 | "isolatedModules": true, 15 | "jsx": "preserve", 16 | "incremental": true 17 | }, 18 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"], 19 | "exclude": ["node_modules"] 20 | } 21 | -------------------------------------------------------------------------------- /example/utils/generic.ts: -------------------------------------------------------------------------------- 1 | export function pluralize(count: number, singular: string, plural?: string) { 2 | return count === 1 ? singular : plural || singular + 's' 3 | } 4 | 5 | export function ensureArray(data: T | T[]): T[] { 6 | return Array.isArray(data) ? data : [data] 7 | } 8 | 9 | export const firstStr = (arrOrStr?: string[] | string) => 10 | arrOrStr ? ensureArray(arrOrStr)[0] ?? '' : '' 11 | -------------------------------------------------------------------------------- /jestconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "transform": { 3 | "^.+\\.(t|j)sx?$": "ts-jest" 4 | }, 5 | "testRegex": "/test/.*\\.test\\.ts$", 6 | "collectCoverageFrom": ["src/**/*.{ts,js}"] 7 | } 8 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "worker-typescript-template", 3 | "version": "1.0.0", 4 | "description": "Cloudflare worker TypeScript template", 5 | "main": "dist/worker.js", 6 | "scripts": { 7 | "build": "webpack", 8 | "format": "prettier --write '*.{json,js}' 'src/**/*.{js,ts}' 'test/**/*.{js,ts}'", 9 | "lint": "eslint --max-warnings=0 src && prettier --check '*.{json,js}' 'src/**/*.{js,ts}' 'test/**/*.{js,ts}'", 10 | "test": "jest --config jestconfig.json --verbose" 11 | }, 12 | "author": "author", 13 | "license": "MIT OR Apache-2.0", 14 | "eslintConfig": { 15 | "root": true, 16 | "extends": [ 17 | "typescript", 18 | "prettier" 19 | ] 20 | }, 21 | "dependencies": { 22 | "@tsndr/cloudflare-worker-jwt": "github:alaister/cloudflare-worker-jwt#main", 23 | "cookie": "^0.4.2" 24 | }, 25 | "devDependencies": { 26 | "@cloudflare/workers-types": "^3.0.0", 27 | "@types/cookie": "^0.4.1", 28 | "@types/jest": "^26.0.23", 29 | "@types/service-worker-mock": "^2.0.1", 30 | "@typescript-eslint/eslint-plugin": "^4.16.1", 31 | "@typescript-eslint/parser": "^4.16.1", 32 | "eslint": "^7.21.0", 33 | "eslint-config-prettier": "^8.1.0", 34 | "eslint-config-typescript": "^3.0.0", 35 | "jest": "^27.0.1", 36 | "prettier": "^2.3.0", 37 | "service-worker-mock": "^2.0.5", 38 | "ts-jest": "^27.0.1", 39 | "ts-loader": "^9.2.2", 40 | "typescript": "^4.3.2", 41 | "webpack": "^5.38.1", 42 | "webpack-cli": "^4.7.0", 43 | "wrangler": "^0.0.17" 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /schema.sql: -------------------------------------------------------------------------------- 1 | create table "sessions"( 2 | "id" uuid not null primary key default uuid_generate_v4(), 3 | "created_at" timestamptz not null default now(), 4 | "expires_at" timestamptz not null default now() + interval '1 year', 5 | "user_id" uuid not null references auth.users("id") on delete cascade on update cascade, 6 | "ip" text, 7 | "country" text, 8 | "user_agent" text 9 | ); 10 | 11 | create index on "sessions"("user_id"); 12 | 13 | alter table "sessions" enable row level security; 14 | 15 | create policy "can view own sessions" on "sessions" 16 | for select using ( 17 | user_id = auth.uid() 18 | ); -------------------------------------------------------------------------------- /src/cors.ts: -------------------------------------------------------------------------------- 1 | export function removeCors(response: Response) { 2 | response.headers.delete('access-control-allow-credentials') 3 | response.headers.delete('access-control-allow-headers') 4 | response.headers.delete('access-control-allow-methods') 5 | response.headers.delete('access-control-allow-origin') 6 | } 7 | -------------------------------------------------------------------------------- /src/handler.ts: -------------------------------------------------------------------------------- 1 | import jwt from '@tsndr/cloudflare-worker-jwt' 2 | import { serialize } from 'cookie' 3 | import { removeCors } from './cors' 4 | import { createSession, deleteSession, getSession } from './sessions' 5 | import { User } from './types' 6 | import { websocket } from './websocket' 7 | 8 | declare global { 9 | const SESSIONS_KV: KVNamespace 10 | const JWT_SECRET: string 11 | const SUPABASE_HOSTNAME: string 12 | const SUPABASE_ANON_KEY: string 13 | const SUPABASE_SERVICE_KEY: string 14 | } 15 | 16 | export async function handleRequest(request: Request): Promise { 17 | const url = new URL(request.url) 18 | 19 | // URL pathname can optionally start with /supabase 20 | if (url.pathname.startsWith('/supabase')) { 21 | url.pathname = url.pathname.slice(9) 22 | } 23 | 24 | const upgradeHeader = request.headers.get('Upgrade') 25 | if ( 26 | upgradeHeader === 'websocket' && 27 | url.pathname === '/realtime/v1/websocket' 28 | ) { 29 | // TODO: check origin matches to prevent CSWSH 30 | 31 | const accessToken = 32 | (await getSession(request.headers.get('Cookie')))?.token ?? 33 | SUPABASE_ANON_KEY 34 | 35 | const [client, server] = Object.values(new WebSocketPair()) 36 | server.accept() 37 | 38 | const supabaseWS = await websocket( 39 | `https://${SUPABASE_HOSTNAME}/realtime/v1/websocket?apikey=${SUPABASE_ANON_KEY}&vsn=1.0.0`, 40 | ) 41 | supabaseWS.accept() 42 | 43 | supabaseWS.addEventListener('message', (event) => { 44 | server.send(event.data) 45 | }) 46 | 47 | server.addEventListener('message', (event) => { 48 | try { 49 | if (typeof event.data !== 'string') { 50 | throw new Error('message is not json') 51 | } 52 | 53 | const data = JSON.parse(event.data) 54 | 55 | if (data.event === 'phx_join') { 56 | supabaseWS.send( 57 | JSON.stringify({ 58 | ...data, 59 | payload: { 60 | ...data.payload, 61 | user_token: accessToken, 62 | }, 63 | }), 64 | ) 65 | 66 | return 67 | } 68 | 69 | if (data.event === 'access_token') { 70 | supabaseWS.send( 71 | JSON.stringify({ 72 | ...data, 73 | payload: { 74 | ...data.payload, 75 | access_token: accessToken, 76 | }, 77 | }), 78 | ) 79 | 80 | return 81 | } 82 | } catch (error) { 83 | // eat error and forward the request as is 84 | } 85 | 86 | supabaseWS.send(event.data) 87 | }) 88 | 89 | return new Response(null, { 90 | status: 101, 91 | webSocket: client, 92 | }) 93 | } 94 | 95 | const supabaseUrl = new URL(url.toString()) 96 | supabaseUrl.hostname = SUPABASE_HOSTNAME 97 | 98 | const supabaseRequest = new Request(request) 99 | supabaseRequest.headers.set('apikey', SUPABASE_ANON_KEY) 100 | supabaseRequest.headers.set('Origin', url.origin) 101 | supabaseRequest.headers.set('Authorization', `Bearer ${SUPABASE_ANON_KEY}`) 102 | 103 | if (request.method === 'GET' && url.pathname === '/auth/v1/authorize') { 104 | const supabaseResponse = await fetch( 105 | supabaseUrl.toString(), 106 | supabaseRequest, 107 | ) 108 | 109 | const response = new Response( 110 | supabaseResponse.clone().body, 111 | supabaseResponse, 112 | ) 113 | 114 | const location = response.headers.get('location') 115 | if (location) { 116 | const locationURL = new URL(location) 117 | 118 | const redirectURI = locationURL.searchParams.get('redirect_uri') 119 | if (redirectURI) { 120 | locationURL.searchParams.set( 121 | 'redirect_uri', 122 | `${url.origin}/supabase/auth/v1/callback`, 123 | ) 124 | 125 | response.headers.set('location', locationURL.toString()) 126 | } 127 | } 128 | 129 | removeCors(response) 130 | return response 131 | } 132 | 133 | if (request.method === 'GET' && url.pathname === '/auth/v1/callback') { 134 | const supabaseResponse = await fetch( 135 | supabaseUrl.toString(), 136 | supabaseRequest, 137 | ) 138 | 139 | const response = new Response( 140 | supabaseResponse.clone().body, 141 | supabaseResponse, 142 | ) 143 | 144 | // TODO: once this has been fixed: https://github.com/supabase/supabase/discussions/1192#discussioncomment-848941 145 | // const location = response.headers.get('location') 146 | // if (location) { 147 | // console.log('location:', location) 148 | // const locationURL = new URL(location) 149 | 150 | // const redirectURI = locationURL.searchParams.get('redirect_uri') 151 | // if (redirectURI) { 152 | // locationURL.searchParams.set( 153 | // 'redirect_uri', 154 | // `${url.origin}/supabase/auth/v1/callback`, 155 | // ) 156 | 157 | // response.headers.set('location', locationURL.toString()) 158 | // } 159 | // } 160 | 161 | removeCors(response) 162 | return response 163 | } 164 | 165 | if ( 166 | request.method === 'POST' && 167 | (url.pathname === '/auth/v1/token' || url.pathname === '/auth/v1/signup') 168 | ) { 169 | const supabaseResponse = await fetch( 170 | supabaseUrl.toString(), 171 | supabaseRequest, 172 | ) 173 | 174 | const response = new Response( 175 | supabaseResponse.clone().body, 176 | supabaseResponse, 177 | ) 178 | 179 | const body = await supabaseResponse.json<{ 180 | user?: User 181 | access_token?: string 182 | }>() 183 | 184 | if (body.user && body.access_token) { 185 | const { user } = body 186 | const sessionId = crypto.randomUUID() 187 | const expires = Math.floor(Date.now() / 1000) + 31540000 // in 1 year 188 | 189 | const token = await jwt.sign( 190 | { 191 | aud: 'authenticated', 192 | sub: user.id, 193 | role: 'authenticated', 194 | exp: expires, 195 | sid: sessionId, 196 | }, 197 | JWT_SECRET, 198 | ) 199 | 200 | await createSession({ 201 | request, 202 | sessionId, 203 | user, 204 | token, 205 | expires, 206 | }) 207 | 208 | const cookie = serialize('sb-session-id', sessionId, { 209 | expires: new Date(expires * 1000), 210 | path: '/', 211 | secure: true, 212 | httpOnly: true, 213 | sameSite: 'lax', 214 | }) 215 | 216 | response.headers.set('Set-Cookie', cookie) 217 | } 218 | 219 | removeCors(response) 220 | 221 | return response 222 | } 223 | 224 | const session = await getSession(request.headers.get('Cookie')) 225 | 226 | if (request.method === 'GET' && url.pathname === '/edge/v1/session') { 227 | if (!session) { 228 | return new Response(JSON.stringify({ message: 'Unauthenticated' }), { 229 | status: 401, 230 | headers: { 231 | 'Content-Type': 'application/json', 232 | }, 233 | }) 234 | } 235 | 236 | return new Response( 237 | // Filter out token 238 | JSON.stringify( 239 | Object.fromEntries( 240 | Object.entries(session).filter(([key]) => key !== 'token'), 241 | ), 242 | ), 243 | { 244 | headers: { 245 | 'Content-Type': 'application/json', 246 | }, 247 | }, 248 | ) 249 | } 250 | 251 | if ( 252 | request.method === 'DELETE' && 253 | url.pathname.startsWith('/edge/v1/sessions/') 254 | ) { 255 | if (!session) { 256 | return new Response(null, { status: 404 }) 257 | } 258 | 259 | const sessionId = url.pathname.slice(18) 260 | 261 | const response = await fetch( 262 | `https://${SUPABASE_HOSTNAME}/rest/v1/sessions?id=eq.${sessionId}&user_id=eq.${session.user.id}`, 263 | { 264 | method: 'GET', 265 | headers: { 266 | Accept: 'application/vnd.pgrst.object+json', 267 | apikey: SUPABASE_ANON_KEY, 268 | Authorization: `Bearer ${SUPABASE_SERVICE_KEY}`, 269 | }, 270 | }, 271 | ) 272 | 273 | if (response.status !== 200) { 274 | return new Response(null, { status: 404 }) 275 | } 276 | 277 | const dbSession = (await response.json()) as { id?: string } | undefined 278 | 279 | if (!dbSession?.id) { 280 | return new Response(null, { status: 404 }) 281 | } 282 | 283 | await deleteSession(dbSession.id) 284 | 285 | return new Response( 286 | // Filter out token 287 | JSON.stringify({ id: sessionId }), 288 | { 289 | headers: { 290 | 'Content-Type': 'application/json', 291 | }, 292 | }, 293 | ) 294 | } 295 | 296 | const accessToken = session?.token 297 | supabaseRequest.headers.set( 298 | 'Authorization', 299 | `Bearer ${accessToken ?? SUPABASE_ANON_KEY}`, 300 | ) 301 | 302 | const supabaseResponse = await fetch(supabaseUrl.toString(), supabaseRequest) 303 | // We must copy the response to avoid the "Can't modify immutable headers." error 304 | const response = new Response(supabaseResponse.body, supabaseResponse) 305 | removeCors(response) 306 | 307 | if (request.method === 'POST' && url.pathname === '/auth/v1/logout') { 308 | if (session) { 309 | await deleteSession(session.id) 310 | 311 | const cookie = serialize('sb-session-id', '', { 312 | expires: new Date(0), 313 | path: '/', 314 | secure: true, 315 | httpOnly: true, 316 | sameSite: 'lax', 317 | }) 318 | response.headers.set('Set-Cookie', cookie) 319 | } 320 | } 321 | 322 | return response 323 | } 324 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { handleRequest } from './handler' 2 | 3 | addEventListener('fetch', (event) => { 4 | event.respondWith(handleRequest(event.request)) 5 | }) 6 | -------------------------------------------------------------------------------- /src/sessions.ts: -------------------------------------------------------------------------------- 1 | import { parse } from 'cookie' 2 | import { Session, User, Without } from './types' 3 | 4 | export async function getSession(cookiesStr?: string | null) { 5 | if (cookiesStr) { 6 | const cookies = parse(cookiesStr) 7 | const sessionId = cookies['sb-session-id'] 8 | if (sessionId) { 9 | const session = await SESSIONS_KV.get>( 10 | sessionId, 11 | 'json', 12 | ) 13 | if (session) { 14 | return { id: sessionId, ...session } 15 | } 16 | } 17 | } 18 | } 19 | 20 | type CreateSessionOptions = { 21 | sessionId: string 22 | user: User 23 | token: string 24 | request: Request 25 | expires: number 26 | } 27 | 28 | export async function createSession({ 29 | request, 30 | sessionId, 31 | user, 32 | token, 33 | expires, 34 | }: CreateSessionOptions) { 35 | await Promise.all([ 36 | SESSIONS_KV.put( 37 | sessionId, 38 | JSON.stringify({ 39 | user, 40 | token, 41 | }), 42 | { 43 | expiration: expires, 44 | }, 45 | ), 46 | fetch(`https://${SUPABASE_HOSTNAME}/rest/v1/sessions`, { 47 | method: 'POST', 48 | body: JSON.stringify({ 49 | id: sessionId, 50 | user_id: user.id, 51 | expires_at: new Date(expires * 1000).toISOString(), 52 | ip: request.headers.get('CF-Connecting-IP'), 53 | user_agent: request.headers.get('User-Agent'), 54 | country: request.cf?.country ?? null, 55 | }), 56 | headers: { 57 | 'Content-Type': 'application/json', 58 | apikey: SUPABASE_ANON_KEY, 59 | Authorization: `Bearer ${SUPABASE_SERVICE_KEY}`, 60 | }, 61 | }), 62 | ]) 63 | } 64 | 65 | export async function deleteSession(sessionId: string) { 66 | await Promise.all([ 67 | SESSIONS_KV.delete(sessionId), 68 | fetch(`https://${SUPABASE_HOSTNAME}/rest/v1/sessions?id=eq.${sessionId}`, { 69 | method: 'DELETE', 70 | headers: { 71 | apikey: SUPABASE_ANON_KEY, 72 | Authorization: `Bearer ${SUPABASE_SERVICE_KEY}`, 73 | }, 74 | }), 75 | ]) 76 | } 77 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-explicit-any */ 2 | 3 | export type Without = Pick> 4 | 5 | export interface UserIdentity { 6 | id: string 7 | user_id: string 8 | identity_data: { 9 | [key: string]: any 10 | } 11 | provider: string 12 | created_at: string 13 | last_sign_in_at: string 14 | updated_at?: string 15 | } 16 | 17 | export interface User { 18 | id: string 19 | app_metadata: { 20 | provider?: string 21 | [key: string]: any 22 | } 23 | user_metadata: { 24 | [key: string]: any 25 | } 26 | aud: string 27 | confirmation_sent_at?: string 28 | recovery_sent_at?: string 29 | invited_at?: string 30 | action_link?: string 31 | email?: string 32 | phone?: string 33 | created_at: string 34 | confirmed_at?: string 35 | email_confirmed_at?: string 36 | phone_confirmed_at?: string 37 | last_sign_in_at?: string 38 | role?: string 39 | updated_at?: string 40 | identities?: UserIdentity[] 41 | } 42 | 43 | export interface Session { 44 | id: string 45 | user: User 46 | token: string 47 | } 48 | -------------------------------------------------------------------------------- /src/websocket.ts: -------------------------------------------------------------------------------- 1 | export async function websocket(url: string) { 2 | const resp = await fetch(url, { 3 | headers: { 4 | Upgrade: 'websocket', 5 | }, 6 | }) 7 | 8 | const ws = resp.webSocket 9 | if (!ws) { 10 | throw new Error("server didn't accept WebSocket") 11 | } 12 | 13 | return ws 14 | } 15 | -------------------------------------------------------------------------------- /test/handler.test.ts: -------------------------------------------------------------------------------- 1 | import { handleRequest } from '../src/handler' 2 | import makeServiceWorkerEnv from 'service-worker-mock' 3 | 4 | declare var global: any 5 | 6 | describe('handle', () => { 7 | beforeEach(() => { 8 | Object.assign(global, makeServiceWorkerEnv()) 9 | jest.resetModules() 10 | }) 11 | 12 | test('handle GET', async () => { 13 | const result = await handleRequest(new Request('/', { method: 'GET' })) 14 | expect(result.status).toEqual(200) 15 | const text = await result.text() 16 | expect(text).toEqual('request method: GET') 17 | }) 18 | }) 19 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "outDir": "./dist", 4 | "module": "commonjs", 5 | "target": "esnext", 6 | "lib": ["esnext"], 7 | "alwaysStrict": true, 8 | "strict": true, 9 | "preserveConstEnums": true, 10 | "moduleResolution": "node", 11 | "sourceMap": true, 12 | "esModuleInterop": true, 13 | "types": [ 14 | "@cloudflare/workers-types", 15 | "@types/jest", 16 | "@types/service-worker-mock" 17 | ] 18 | }, 19 | "include": ["src"], 20 | "exclude": ["node_modules", "dist", "test"] 21 | } 22 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | 3 | module.exports = { 4 | entry: './src/index.ts', 5 | output: { 6 | filename: 'worker.js', 7 | path: path.join(__dirname, 'dist'), 8 | }, 9 | devtool: 'cheap-module-source-map', 10 | mode: 'development', 11 | resolve: { 12 | extensions: ['.ts', '.tsx', '.js'], 13 | }, 14 | module: { 15 | rules: [ 16 | { 17 | test: /\.tsx?$/, 18 | loader: 'ts-loader', 19 | options: { 20 | // transpileOnly is useful to skip typescript checks occasionally: 21 | // transpileOnly: true, 22 | }, 23 | }, 24 | ], 25 | }, 26 | } 27 | -------------------------------------------------------------------------------- /wrangler.toml: -------------------------------------------------------------------------------- 1 | name = "supabase-cookie-auth-proxy" 2 | type = "javascript" 3 | zone_id = "" 4 | account_id = "" 5 | route = "" 6 | workers_dev = true 7 | compatibility_date = "2022-02-28" 8 | kv_namespaces = [ 9 | { binding = "SESSIONS_KV", id = "" } 10 | ] 11 | 12 | [vars] 13 | SUPABASE_HOSTNAME = "" 14 | SUPABASE_ANON_KEY = "" 15 | 16 | [build] 17 | command = "npm install && npm run build" 18 | [build.upload] 19 | format = "service-worker" 20 | --------------------------------------------------------------------------------