├── .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 | [](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 | [](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 |
27 |
32 |
33 |
50 |
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 |
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 |
17 | {data?.posts.map((post) => (
18 |
19 |
20 | {post.title}
21 |
22 |
23 | ))}
24 |
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 |
77 | {isCurrentSession ? 'Sign Out' : 'Revoke'}
78 |
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 |
9 |
14 |
15 |
24 |
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 |
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 |
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 |
53 |
59 |
63 |
64 | Sign in with GitHub
65 |
66 |
*/}
67 |
68 | {/*
69 |
75 |
76 | Or
77 |
78 |
*/}
79 |
80 |
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 |
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 |
--------------------------------------------------------------------------------