├── .babelrc
├── .env.local.example
├── .eslintrc.js
├── .gitignore
├── .vercelignore
├── README.md
├── components
├── App.js
├── InfoBox.js
├── LogIn.js
├── SignUp.js
├── Thing.js
├── ThingsList.js
└── UserContext.js
├── lib
├── cookieConfig.js
├── cookieHelper.js
├── fauna
│ ├── config.js
│ ├── exampleFunctions.js
│ └── exampleRoles.js
├── graphql
│ ├── localSchema.js
│ ├── overrideSchema.js
│ ├── remoteSchema.js
│ └── schema.js
└── graphqlHelper.js
├── package.json
├── pages
├── _app.js
├── api
│ └── graphql.js
└── index.js
├── scripts
├── config.js
├── createDoc.js
├── createRoles.js
├── faunaSchema.graphql
├── manageKeys.js
├── setup.js
├── updateFunctions.js
└── uploadSchema.js
└── yarn.lock
/.babelrc:
--------------------------------------------------------------------------------
1 | {
2 | "presets": [
3 | "next/babel"
4 | ],
5 | "plugins": [
6 | [
7 | "graphql-tag"
8 | ]
9 | ]
10 | }
11 |
--------------------------------------------------------------------------------
/.env.local.example:
--------------------------------------------------------------------------------
1 | FAUNADB_ADMIN_SECRET=
2 | FAUNADB_PUBLIC_ACCESS_KEY=
--------------------------------------------------------------------------------
/.eslintrc.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | "env": {
3 | "browser": true,
4 | "es2020": true,
5 | "node": true
6 | },
7 | "extends": [
8 | "eslint:recommended",
9 | "plugin:react/recommended"
10 | ],
11 | "parserOptions": {
12 | "ecmaFeatures": {
13 | "jsx": true
14 | },
15 | "ecmaVersion": 11,
16 | "sourceType": "module"
17 | },
18 | "plugins": [
19 | "react"
20 | ],
21 | "rules": {
22 | }
23 | };
24 |
--------------------------------------------------------------------------------
/.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 | .env
33 |
34 | .vercel
35 |
36 | /.idea*
--------------------------------------------------------------------------------
/.vercelignore:
--------------------------------------------------------------------------------
1 | scripts
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Next.js, FaunaDB and `httpOnly` Cookie Auth Flow with GraphQL
2 |
3 | The present example explains how to setup an `httpOnly` cookie auth flow with Next.js and Fauna, using Apollo Server and react-query/graphql-request on the client.
4 |
5 | These are some of the features that this setup provides:
6 |
7 | 1. The auth flow lives inside the Fauna dashboard with the help of [User Defined Functions (UDFs)](https://docs.fauna.com/fauna/current/api/graphql/functions) and [User Defined Roles (UDRs)](https://docs.fauna.com/fauna/current/security/roles.html). UDFs and UDRs are one flexible medium offered in Fauna by which you can implement business logic and [Attribute-based Access Control (ABAC)](https://docs.fauna.com/fauna/current/security/abac.html) to any document, function and index in the database.
8 | 2. A GraphQL server using [schema stitching](https://www.apollographql.com/docs/apollo-server/features/schema-stitching/).
9 | 1. This is helpful because it provides the maximum possible flexibility in terms of API integration through GraphQL, given that Fauna doesn't support [Apollo Federation](https://www.apollographql.com/docs/apollo-server/federation/introduction/) yet.
10 | 2. In other words, by using schema stitching we can extend our GraphQL endpoint to connect with other APIs or basically run any arbitrary code in-between Fauna requests (similar to a proxy).
11 | 3. This is specially useful if you don't want to have several API endpoints and want to manage everything through GraphQL.
12 | 3. The example also provides a [series of scripts](/examples/with-cookie-auth-fauna-apollo-server/scripts) that can be executed with a single command, that help you manage your database quickly on a day to day basis, from pushing a newly created schema, generating new keys, updating your functions or even creating a whole new database from scratch. This is incredibly useful when getting started in order to fasten things up.
13 | 4. An httpOnly cookie based auth flow.
14 | 5. Token validation on refresh and window focus with [`react-query`](https://github.com/tannerlinsley/react-query#useQuery). This is useful because it keeps the auth state changes in sync with the client, for example if the user token dissapears on the DB (for any reason), it logs out the user in any other client automatically.
15 |
16 | This is an advanced example that assumes a lot of concepts, and it strives to provide the most ample bed from which you can get started with both FaunaDB and Next.js. If you are looking for a simpler approach which doesn't include GraphQL, UDFs or User Defined Roles, and only handles a cookie based authentication plus token validation, be sure to check out the example [with-cookie-auth-fauna](https://github.com/vercel/next.js/tree/canary/examples/with-cookie-auth-fauna).
17 |
18 | ## Demo
19 |
20 | [https://with-graphql-faunadb-cookie-auth.now.sh/](https://with-graphql-faunadb-cookie-auth.now.sh/)
21 |
22 | ## How to use
23 |
24 | ### Using `create-next-app`
25 |
26 | 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:
27 |
28 | ```bash
29 | npx create-next-app --example with-cookie-auth-fauna-apollo-server with-cookie-auth-fauna-apollo-server-app
30 | # or
31 | yarn create next-app --example with-cookie-auth-fauna-apollo-server with-cookie-auth-fauna-apollo-server-app
32 | ```
33 |
34 | ### Download manually
35 |
36 | Download the example:
37 |
38 | ```bash
39 | curl https://codeload.github.com/vercel/next.js/tar.gz/canary | tar -xz --strip=2 next.js-canary/examples/with-cookie-auth-fauna-apollo-server
40 | cd with-cookie-auth-fauna-apollo-server
41 | ```
42 |
43 | Install it and run:
44 |
45 | ```bash
46 | npm install
47 | npm run dev
48 | # or
49 | yarn
50 | yarn dev
51 | ```
52 |
53 | Deploy it to the cloud with [Vercel](https://vercel.com/import?filter=next.js&utm_source=github&utm_medium=readme&utm_campaign=next-example) ([Documentation](https://nextjs.org/docs/deployment)).
54 |
55 | ### Run locally
56 |
57 | 1. Create an account on [Fauna](https://fauna.com/)
58 | 2. In the [FaunaDB Console](https://dashboard.fauna.com/), click "New Database". Name it whatever you like and click "Save".
59 | 3. Now go to "Security" and click "New Key". Let's create an "Admin" key, name it whatever you want.
60 | 4. Copy the newly created "Admin" key and create an `.env.local` file. Paste the key along the name `FAUNADB_ADMIN_SECRET`.
61 | 5. On you console, while positioned on the root folder, execute:
62 |
63 | ```
64 | node -e 'require("./scripts/setup.js").full()'
65 | ```
66 |
67 | This will create all the roles, functions and lastly a public key necessary to connect to the DB securely.
68 |
69 | 6. Copy the last line of the previous command into the `.env.local` file.
70 | 7. (Optional) Delete the admin key both from the [FaunaDB Console](https://dashboard.fauna.com/) and the `.env.local` file.
71 | 8. Run `yarn && yarn dev`
72 |
73 | ### Deploy on Vercel
74 |
75 | You can deploy this app to the cloud with [Vercel](https://vercel.com?utm_source=github&utm_medium=readme&utm_campaign=next-example) ([Documentation](https://nextjs.org/docs/deployment)).
76 |
77 | #### Deploy Your Local Project
78 |
79 | To deploy your local project to Vercel, push it to GitHub/GitLab/Bitbucket and [import to Vercel](https://vercel.com/import/git?utm_source=github&utm_medium=readme&utm_campaign=next-example).
80 |
81 | **Important**: When you import your project on Vercel, make sure to click on **Environment Variables** and set them to match your `.env.local` file.
82 |
--------------------------------------------------------------------------------
/components/App.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | export default function App({ children }) {
4 | return (
5 |
6 | {children}
7 |
47 |
48 | );
49 | }
50 |
--------------------------------------------------------------------------------
/components/InfoBox.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | export default function InfoBox({ children }) {
4 | return (
5 |
6 |
13 | {children}
14 |
15 | );
16 | }
17 |
--------------------------------------------------------------------------------
/components/LogIn.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { useMutation } from 'react-query';
3 | import { request } from 'graphql-request';
4 |
5 | import { user } from './UserContext';
6 |
7 | const LOGIN_USER = `
8 | mutation loginUser($data: LoginUserInput!) {
9 | loginUser(data: $data) {
10 | userId
11 | userToken
12 | }
13 | }
14 | `;
15 |
16 | export default function LogIn() {
17 | const { setId } = user();
18 | const [loginUser, { status: loginStatus }] = useMutation(
19 | (variables) => {
20 | return request('/api/graphql', LOGIN_USER, variables);
21 | },
22 | {
23 | onSuccess: (data) => {
24 | setId(data.loginUser.userId);
25 | localStorage.setItem('userId', data.loginUser.userId);
26 | },
27 | onError: (err) => {
28 | console.log(err.message);
29 | },
30 | }
31 | );
32 |
33 | const handleSubmit = async (event) => {
34 | event.preventDefault();
35 | const form = event.target;
36 | const formData = new window.FormData(form);
37 | const email = formData.get('email');
38 | const password = formData.get('password');
39 |
40 | await loginUser({
41 | data: {
42 | email,
43 | password,
44 | },
45 | });
46 |
47 | form.reset();
48 | };
49 |
50 | return (
51 |
73 | );
74 | }
75 |
--------------------------------------------------------------------------------
/components/SignUp.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { useMutation } from 'react-query';
3 | import { request } from 'graphql-request';
4 |
5 | import { user } from './UserContext';
6 |
7 | const SIGNUP_USER = `
8 | mutation signupUser($data: CreateUserInput!) {
9 | signupUser(data: $data) {
10 | userId
11 | userToken
12 | }
13 | }
14 | `;
15 |
16 | export default function SignUp({ setIsUserLoggedIn }) {
17 | const { setId } = user();
18 | const [signupUser, { status: signupStatus }] = useMutation(
19 | (variables) => {
20 | return request('/api/graphql', SIGNUP_USER, variables);
21 | },
22 | {
23 | onSuccess: (data) => {
24 | setId(data.signupUser.userId);
25 | localStorage.setItem('userId', data.signupUser.userId);
26 | },
27 | onError: (err) => {
28 | console.log(err.message);
29 | },
30 | }
31 | );
32 |
33 | const handleSubmit = async (event) => {
34 | event.preventDefault();
35 | const form = event.target;
36 | const formData = new window.FormData(form);
37 | const email = formData.get('email');
38 | const password = formData.get('password');
39 | form.reset();
40 |
41 | await signupUser({
42 | data: {
43 | email,
44 | password,
45 | role: 'FREE_USER',
46 | },
47 | });
48 | };
49 |
50 | return (
51 |
73 | );
74 | }
75 |
--------------------------------------------------------------------------------
/components/Thing.js:
--------------------------------------------------------------------------------
1 | import React, { useState } from 'react';
2 | import { useMutation, queryCache } from 'react-query';
3 | import { request } from 'graphql-request';
4 |
5 | const DELETE_THING = `
6 | mutation DeleteThing($id: ID!) {
7 | deleteThing(id: $id) {
8 | _id
9 | }
10 | }
11 | `;
12 |
13 | const UPDATE_THING = `
14 | mutation UpdateThing($id: ID!, $data: ThingInput!) {
15 | updateThing(id: $id, data: $data) {
16 | _id
17 | name
18 | }
19 | }
20 | `;
21 |
22 | export default function Thing({ id, name }) {
23 | const [isDeleting, setIsDeleting] = useState(false);
24 | const [isUpdating, setIsUpdating] = useState(false);
25 |
26 | if (!name) name = empty ;
27 |
28 | const [deleteThing, { isLoading: isDeleteThingLoading }] = useMutation(
29 | async (variables) => request('/api/graphql', DELETE_THING, variables),
30 | {
31 | onMutate: ({ id }) => {
32 | queryCache.cancelQueries('findAllThings');
33 | const previousValue = queryCache.getQueryData('findAllThings');
34 | /* The value returned from this function will be passed to both the onError
35 | * and onSettled functions in the event of a mutation failure and can be useful
36 | * for rolling back optimistic updates
37 | */
38 | const newData = {
39 | findAllThings: {
40 | data: previousValue.findAllThings.data.filter((o) => o._id !== id),
41 | },
42 | };
43 |
44 | queryCache.setQueryData('findAllThings', newData);
45 | return previousValue;
46 | },
47 | onSuccess: (data) => {
48 | console.log('Delete success', data);
49 | },
50 | onError: (err, variables, previousValue) => {
51 | console.log(err);
52 | queryCache.setQueryData('findAllThings', previousValue);
53 | },
54 | }
55 | );
56 |
57 | const [updateThing, { isLoading: isUpdateThingLoading }] = useMutation(
58 | async (variables) => request('/api/graphql', UPDATE_THING, variables),
59 | {
60 | onMutate: ({ data, id }) => {
61 | queryCache.cancelQueries('findAllThings');
62 | const previousValue = queryCache.getQueryData('findAllThings');
63 | /* The value returned from this function will be passed to both the onError
64 | * and onSettled functions in the event of a mutation failure and can be useful
65 | * for rolling back optimistic updates
66 | */
67 | const newData = {
68 | findAllThings: {
69 | data: previousValue.findAllThings.data.map((el) => {
70 | if (el._id === id) {
71 | return Object.assign({}, el, { name: data.name });
72 | }
73 | return el;
74 | }),
75 | },
76 | };
77 | queryCache.setQueryData('findAllThings', newData);
78 | return previousValue;
79 | },
80 | onSuccess: (data) => {
81 | console.log('Update success', data);
82 | },
83 | onError: (err, variables, previousValue) => {
84 | console.log(err);
85 | queryCache.setQueryData('findAllThings', previousValue);
86 | },
87 | }
88 | );
89 |
90 | const handleUpdateThing = async (event) => {
91 | event.preventDefault();
92 | const form = event.target;
93 | const formData = new window.FormData(form);
94 | const thingName = formData.get('name');
95 |
96 | await updateThing({
97 | data: {
98 | name: thingName,
99 | },
100 | id,
101 | });
102 | form.reset();
103 | setIsUpdating(false);
104 | };
105 |
106 | let fullRender = (
107 |
108 | setIsUpdating(true)}>{name}
109 | setIsDeleting(true)}
112 | disabled={
113 | isDeleteThingLoading === 'loading' ||
114 | isUpdateThingLoading === 'loading'
115 | }
116 | >
117 | Delete
118 |
119 |
126 |
127 | );
128 | if (!isDeleting && isUpdating) {
129 | fullRender = (
130 |
131 |
154 |
161 |
162 | );
163 | }
164 | if (isDeleting && !isUpdating) {
165 | fullRender = (
166 |
167 | {name}
168 | setIsDeleting(false)}
171 | disabled={
172 | isDeleteThingLoading === 'loading' ||
173 | isUpdateThingLoading === 'loading'
174 | }
175 | >
176 | Cancel
177 |
178 | await deleteThing({ id: id })}
181 | disabled={
182 | isDeleteThingLoading === 'loading' ||
183 | isUpdateThingLoading === 'loading'
184 | }
185 | >
186 | Yes, delete
187 |
188 |
195 |
196 | );
197 | }
198 |
199 | return fullRender;
200 | }
201 |
--------------------------------------------------------------------------------
/components/ThingsList.js:
--------------------------------------------------------------------------------
1 | import React, { useState } from 'react';
2 | import Thing from './Thing';
3 | import { useQuery, useMutation, queryCache } from 'react-query';
4 | import { request } from 'graphql-request';
5 |
6 | import { user } from './UserContext';
7 |
8 | const GET_THINGS = `
9 | query FindAllThings {
10 | findAllThings {
11 | data {
12 | _id
13 | name
14 | }
15 | }
16 | }
17 | `;
18 |
19 | const CREATE_THING = `
20 | mutation CreateThing($data: ThingInput!) {
21 | createThing(data: $data) {
22 | _id
23 | name
24 | }
25 | }
26 | `;
27 |
28 | export default function ThingList() {
29 | const { id } = user();
30 | const [isCreating, setIsCreating] = useState(false);
31 |
32 | const { data: things, isFetching } = useQuery(
33 | ['findAllThings'],
34 | async () => request('/api/graphql', GET_THINGS),
35 | {
36 | onError: (err) => {
37 | console.log(err);
38 | },
39 | refetchOnWindowFocus: false,
40 | staleTime: Infinity,
41 | cacheTime: Infinity,
42 | }
43 | );
44 |
45 | const [createThing, { status: createThingStatus }] = useMutation(
46 | async (variables) => request('/api/graphql', CREATE_THING, variables),
47 | {
48 | onMutate: ({ data }) => {
49 | queryCache.cancelQueries('findAllThings');
50 | const previousValue = queryCache.getQueryData('findAllThings');
51 | const newThing = {
52 | _id: 'temp-id',
53 | name: data.name,
54 | };
55 | /* The value returned from this function will be passed to both the onError
56 | * and onSettled functions in the event of a mutation failure and can be useful
57 | * for rolling back optimistic updates
58 | */
59 | const newData = {
60 | findAllThings: {
61 | data: [...previousValue.findAllThings.data, newThing],
62 | },
63 | };
64 |
65 | queryCache.setQueryData('findAllThings', newData);
66 | return previousValue;
67 | },
68 | onSuccess: ({ createThing }) => {
69 | queryCache.cancelQueries('findAllThings');
70 | const currentValue = queryCache.getQueryData('findAllThings');
71 | const thingsWithoutLast = currentValue.findAllThings.data.slice(
72 | 0,
73 | currentValue.findAllThings.data.length - 1
74 | );
75 | /* To replace the `temp-id` */
76 | const newLastThing = {
77 | _id: createThing._id,
78 | name: createThing.name,
79 | };
80 | const newData = {
81 | findAllThings: {
82 | data: [...thingsWithoutLast, newLastThing],
83 | },
84 | };
85 |
86 | queryCache.setQueryData('findAllThings', newData);
87 | console.log('Create success', newData);
88 | setIsCreating(false);
89 | },
90 | onError: (err, variables, previousValue) => {
91 | console.log(err.message);
92 | queryCache.setQueryData('findAllThings', previousValue);
93 | },
94 | }
95 | );
96 |
97 | const handleCreateThing = async (event) => {
98 | event.preventDefault();
99 | const form = event.target;
100 | const formData = new window.FormData(form);
101 | const thingName = formData.get('name');
102 |
103 | await createThing({
104 | data: {
105 | owner: {
106 | connect: id,
107 | },
108 | name: thingName,
109 | },
110 | });
111 | form.reset();
112 | };
113 |
114 | let thingsList;
115 | if (things) {
116 | thingsList = [...things.findAllThings.data]
117 | .reverse()
118 | .map((thing) => (
119 |
120 | ));
121 | }
122 |
123 | return (
124 |
125 |
126 | {isCreating ? (
127 | <>
128 |
150 | >
151 | ) : (
152 | setIsCreating(true)}>
153 | Create thing
154 |
155 | )}
156 |
157 |
List of things (click the list item text to update)
158 |
{isFetching ? loading... : thingsList}
159 |
166 |
167 | );
168 | }
169 |
--------------------------------------------------------------------------------
/components/UserContext.js:
--------------------------------------------------------------------------------
1 | import React, { createContext, useState, useContext } from 'react';
2 |
3 | const UserContext = createContext();
4 |
5 | export const UserContextProvider = ({ children }) => {
6 | const [id, setId] = useState('');
7 | return (
8 |
9 | {children}
10 |
11 | );
12 | };
13 |
14 | export const user = () => useContext(UserContext);
15 |
--------------------------------------------------------------------------------
/lib/cookieConfig.js:
--------------------------------------------------------------------------------
1 | export const SECRET_COOKIE_NAME = 'custom_cookie';
2 |
3 | export const setCookieConfig = {
4 | sameSite: 'lax',
5 | secure: process.env.NODE_ENV === 'production',
6 | maxAge: 14 * 24 * 60 * 60 * 1000,
7 | httpOnly: true,
8 | path: '/',
9 | };
10 |
11 | export const unsetCookieConfig = {
12 | sameSite: 'lax',
13 | secure: process.env.NODE_ENV === 'production',
14 | maxAge: -1,
15 | httpOnly: true,
16 | path: '/',
17 | };
18 |
--------------------------------------------------------------------------------
/lib/cookieHelper.js:
--------------------------------------------------------------------------------
1 | import { serialize } from 'cookie';
2 |
3 | /**
4 | * This sets `cookie` on `res` object
5 | * I extended this from an example in Next.js
6 | */
7 | const cookie = (res, name, value, options = {}) => {
8 | if (typeof value !== 'object' && typeof value !== 'string') {
9 | throw new TypeError('cookies must be an object or a string');
10 | }
11 |
12 | if ('maxAge' in options) {
13 | options.expires = new Date(Date.now() + options.maxAge);
14 | options.maxAge /= 1000;
15 | }
16 |
17 | if (typeof value === 'object') {
18 | let cookieArray = Object.keys(value);
19 |
20 | for (let i = 0; i < cookieArray.length; i++) {
21 | let name = cookieArray[i];
22 | cookieArray[i] = serialize(name, value[name], options);
23 | }
24 |
25 | res.setHeader('Set-Cookie', cookieArray);
26 | }
27 |
28 | if (typeof value === 'string') {
29 | const cookieValue = String(value);
30 |
31 | res.setHeader('Set-Cookie', serialize(name, String(cookieValue), options));
32 | }
33 | };
34 |
35 | /**
36 | * Adds `cookie` function on `res.cookie` to set cookies for response
37 | */
38 | const cookieSetter = (handler) => (req, res) => {
39 | res.setCookie = (name, value, options) => cookie(res, name, value, options);
40 |
41 | return handler(req, res);
42 | };
43 |
44 | export default cookieSetter;
45 |
--------------------------------------------------------------------------------
/lib/fauna/config.js:
--------------------------------------------------------------------------------
1 | import faunadb from 'faunadb';
2 |
3 | export const SECRET_COOKIE_NAME = 'custom_cookie';
4 |
5 | // Used for any authed requests.
6 | export const faunaClient = (secret) =>
7 | new faunadb.Client({
8 | secret,
9 | });
10 |
11 | export const setCookieConfig = {
12 | sameSite: 'lax',
13 | secure: process.env.NODE_ENV === 'production',
14 | maxAge: 14 * 24 * 60 * 60 * 1000,
15 | httpOnly: true,
16 | path: '/',
17 | };
18 |
19 | export const unsetCookieConfig = {
20 | sameSite: 'lax',
21 | secure: process.env.NODE_ENV === 'production',
22 | maxAge: -1,
23 | httpOnly: true,
24 | path: '/',
25 | };
26 |
--------------------------------------------------------------------------------
/lib/fauna/exampleFunctions.js:
--------------------------------------------------------------------------------
1 | // create_user
2 | Query(
3 | Lambda(
4 | ['input'],
5 | Create(Collection('User'), {
6 | data: {
7 | email: Select('email', Var('input')),
8 | role: Select('role', Var('input')),
9 | },
10 | credentials: { password: Select('password', Var('input')) },
11 | })
12 | )
13 | );
14 |
15 | // login_user
16 | Query(
17 | Lambda(
18 | ['input'],
19 | Select(
20 | 'secret',
21 | Login(Match(Index('unique_User_email'), Select('email', Var('input'))), {
22 | password: Select('password', Var('input')),
23 | ttl: TimeAdd(Now(), 14, 'days'),
24 | })
25 | )
26 | )
27 | );
28 |
29 | // logout_user
30 | Query(Lambda('_', Logout(true)));
31 |
32 | // signup_user;
33 | Query(
34 | Lambda(
35 | ['input'],
36 | Do(
37 | Call(Function('create_user'), Var('input')),
38 | Call(Function('login_user'), Var('input'))
39 | )
40 | )
41 | );
42 |
43 | // validate_token
44 | Query(Lambda(['token'], Not(IsNull(KeyFromSecret(Var('token'))))));
45 |
--------------------------------------------------------------------------------
/lib/fauna/exampleRoles.js:
--------------------------------------------------------------------------------
1 | // fnc_role_create_user role
2 | CreateRole({
3 | name: 'fnc_role_create_user',
4 | privileges: [
5 | {
6 | resource: Collection('User'),
7 | actions: {
8 | read: true,
9 | create: Query(
10 | Lambda(
11 | 'values',
12 | Equals(Select(['data', 'role'], Var('values')), 'FREE_USER')
13 | )
14 | ),
15 | },
16 | },
17 | ],
18 | });
19 |
20 | // fnc_role_login_user role
21 | CreateRole({
22 | name: 'fnc_role_login_user',
23 | privileges: [
24 | {
25 | resource: Index('unique_User_email'),
26 | actions: {
27 | unrestricted_read: true,
28 | },
29 | },
30 | ],
31 | });
32 |
33 | // fnc_role_logout_user role
34 | CreateRole({
35 | name: 'fnc_role_logout_user',
36 | privileges: [
37 | {
38 | resource: Ref('tokens'),
39 | actions: {
40 | create: true,
41 | read: true,
42 | },
43 | },
44 | ],
45 | });
46 |
47 | // fnc_role_signup_user role
48 | CreateRole({
49 | name: 'fnc_role_signup_user',
50 | privileges: [
51 | {
52 | resource: Function('create_user'),
53 | actions: {
54 | call: true,
55 | },
56 | },
57 | {
58 | resource: Function('login_user'),
59 | actions: {
60 | call: true,
61 | },
62 | },
63 | ],
64 | });
65 |
66 | // fnc_role_validate_token role
67 | CreateRole({
68 | name: 'fnc_role_validate_token',
69 | privileges: [
70 | {
71 | resource: Ref('tokens'),
72 | actions: {
73 | read: true,
74 | },
75 | },
76 | ],
77 | });
78 |
79 | // free_user role
80 | CreateRole({
81 | name: 'free_user',
82 | privileges: [
83 | {
84 | resource: Collection('User'),
85 | actions: {
86 | read: Query(Lambda('userRef', Equals(Identity(), Var('userRef')))),
87 | write: Query(
88 | Lambda(
89 | ['_', 'newData', 'userRef'],
90 | And(
91 | Equals(Identity(), Var('userRef')),
92 | Equals('FREE_USER', Select(['data', 'role'], Var('newData')))
93 | )
94 | )
95 | ),
96 | },
97 | },
98 | {
99 | resource: Function('validate_token'),
100 | actions: {
101 | call: true,
102 | },
103 | },
104 | {
105 | resource: Function('logout_user'),
106 | actions: {
107 | call: true,
108 | },
109 | },
110 | ],
111 | membership: [
112 | {
113 | resource: Collection('User'),
114 | predicate: Query(
115 | Lambda(
116 | 'userRef',
117 | Or(Equals(Select(['data', 'role'], Get(Var('userRef'))), 'FREE_USER'))
118 | )
119 | ),
120 | },
121 | ],
122 | });
123 |
124 | // public role
125 | CreateRole({
126 | name: 'public',
127 | privileges: [
128 | {
129 | resource: Function('signup_user'),
130 | actions: {
131 | call: true,
132 | },
133 | },
134 | {
135 | resource: Function('login_user'),
136 | actions: {
137 | call: true,
138 | },
139 | },
140 | ],
141 | });
142 |
--------------------------------------------------------------------------------
/lib/graphql/localSchema.js:
--------------------------------------------------------------------------------
1 | import { gql } from 'apollo-server-micro';
2 | import cookie from 'cookie';
3 |
4 | import { SECRET_COOKIE_NAME } from '../cookieConfig';
5 |
6 | const localTypeDefs = gql`
7 | type Query {
8 | validCookie: Boolean!
9 | }
10 | `;
11 |
12 | const localResolvers = {
13 | Query: {
14 | validCookie: async (root, args, context) => {
15 | const { token } = context;
16 | const tkValue = await token(context.req, context.res);
17 | /* The context runs before every query or mutation, and since
18 | we are already validating the token in tokenValidation(),
19 | we only use validToken to dinamically pull the cookie
20 | on browser reload.
21 | There's no need to unset or set the cookie here as that
22 | is done in the context. */
23 | let customCookie;
24 | if (context.req.headers.cookie) {
25 | const parsedCookies = cookie.parse(context.req.headers.cookie);
26 | customCookie = parsedCookies[SECRET_COOKIE_NAME];
27 | }
28 | if (tkValue || customCookie) {
29 | console.log(
30 | ' query -- validCookie -- found token/cookie',
31 | tkValue || customCookie
32 | );
33 | return true;
34 | }
35 | console.log(' query -- validCookie -- no valid customCookie or token');
36 | return false;
37 | },
38 | },
39 | };
40 |
41 | export { localTypeDefs, localResolvers };
42 |
--------------------------------------------------------------------------------
/lib/graphql/overrideSchema.js:
--------------------------------------------------------------------------------
1 | import { UserInputError, ApolloError } from 'apollo-server-micro';
2 | import chalk from 'chalk';
3 |
4 | import {
5 | SECRET_COOKIE_NAME,
6 | unsetCookieConfig,
7 | setCookieConfig,
8 | } from '../cookieConfig';
9 | import { delegate } from '../graphqlHelper';
10 |
11 | const createOverrideResolvers = (remoteExecutableSchema) => ({
12 | Mutation: {
13 | loginUser: async (root, args, context, info) => {
14 | const params = [args, context, info];
15 | const { setCookie } = context.res;
16 |
17 | if (!args.data || !args.data.email || !args.data.password) {
18 | throw new UserInputError('Missing input data', {
19 | invalidArgs: Object.keys(args),
20 | });
21 | }
22 |
23 | const data = await delegate(
24 | ...params,
25 | { remoteExecutableSchema },
26 | 'mutation',
27 | 'loginUser'
28 | );
29 |
30 | if (data.userToken) {
31 | console.log(
32 | chalk.cyan(' mutation loginUser -- setting custom cookie')
33 | );
34 | setCookie(SECRET_COOKIE_NAME, data.userToken, setCookieConfig);
35 | return {
36 | userId: data.userId,
37 | };
38 | }
39 |
40 | throw new ApolloError('User token not found');
41 | },
42 | signupUser: async (root, args, context, info) => {
43 | const params = [args, context, info];
44 | const { setCookie } = context.res;
45 |
46 | if (
47 | !args.data ||
48 | !args.data.email ||
49 | !args.data.password ||
50 | !args.data.role
51 | ) {
52 | throw new UserInputError('Missing input data', {
53 | invalidArgs: Object.keys(args),
54 | });
55 | }
56 |
57 | const data = await delegate(
58 | ...params,
59 | { remoteExecutableSchema },
60 | 'mutation',
61 | 'signupUser'
62 | );
63 |
64 | if (data.userToken) {
65 | console.log(
66 | chalk.cyan(' mutation signupUser -- setting custom cookie')
67 | );
68 | setCookie(SECRET_COOKIE_NAME, data.userToken, setCookieConfig);
69 | return {
70 | userId: data.userId,
71 | };
72 | }
73 |
74 | throw new ApolloError('User token not found');
75 | },
76 | logoutUser: async (root, args, context, info) => {
77 | const params = [args, context, info];
78 | const { setCookie } = context.res;
79 |
80 | // Logging out in Fauna means deleting any user specific ABAC tokens
81 | const data = await delegate(
82 | ...params,
83 | { remoteExecutableSchema },
84 | 'mutation',
85 | 'logoutUser'
86 | );
87 | if (data === 'already logged out') return true;
88 | if (data === true) {
89 | console.log(' mutation logoutUser -- Successful. Deleting cookie');
90 | if (data) {
91 | setCookie(SECRET_COOKIE_NAME, '', unsetCookieConfig);
92 | return data;
93 | }
94 | }
95 | console.log(' mutation logoutUser -- Unexpected error');
96 | return data;
97 | },
98 | },
99 | });
100 |
101 | export { createOverrideResolvers };
102 |
--------------------------------------------------------------------------------
/lib/graphql/remoteSchema.js:
--------------------------------------------------------------------------------
1 | // To get this file go to https://dashboard.fauna.com
2 | // Select your database
3 | // Go to "Graphql"
4 | // Click on "Schema" (right side)
5 | // Then download in SDL format
6 | // Then when you paste it be careful with backtics (`) as it messes the template literal
7 | const remoteTypeDefs = `
8 | directive @embedded on OBJECT
9 | directive @collection(name: String!) on OBJECT
10 | directive @index(name: String!) on FIELD_DEFINITION
11 | directive @resolver(
12 | name: String
13 | paginated: Boolean! = false
14 | ) on FIELD_DEFINITION
15 | directive @relation(name: String) on FIELD_DEFINITION
16 | directive @unique(index: String) on FIELD_DEFINITION
17 | input CreateUserInput {
18 | email: String!
19 | password: String!
20 | role: UserRole!
21 | }
22 |
23 | scalar Date
24 |
25 | type LoginRes {
26 | userToken: String
27 | userId: String!
28 | }
29 |
30 | # 'LoginRes' input values
31 | input LoginResInput {
32 | userToken: String
33 | userId: String!
34 | }
35 |
36 | input LoginUserInput {
37 | email: String!
38 | password: String!
39 | }
40 |
41 | # The 'Long' scalar type represents non-fractional signed whole numeric values.
42 | # Long can represent values between -(2^63) and 2^63 - 1.
43 | scalar Long
44 |
45 | type Mutation {
46 | logoutUser: Boolean!
47 | # Update an existing document in the collection of 'User'
48 | updateUser(
49 | # The 'User' document's ID
50 | id: ID!
51 | # 'User' input values
52 | data: UserInput!
53 | ): User
54 | # Create a new document in the collection of 'Thing'
55 | createThing(
56 | # 'Thing' input values
57 | data: ThingInput!
58 | ): Thing!
59 | createUser(data: CreateUserInput!): User!
60 | # Delete an existing document in the collection of 'Thing'
61 | deleteThing(
62 | # The 'Thing' document's ID
63 | id: ID!
64 | ): Thing
65 | loginUser(data: LoginUserInput!): LoginRes!
66 | # Update an existing document in the collection of 'Thing'
67 | updateThing(
68 | # The 'Thing' document's ID
69 | id: ID!
70 | # 'Thing' input values
71 | data: ThingInput!
72 | ): Thing
73 | # Delete an existing document in the collection of 'User'
74 | deleteUser(
75 | # The 'User' document's ID
76 | id: ID!
77 | ): User
78 | signupUser(data: CreateUserInput!): LoginRes!
79 | }
80 |
81 | type Query {
82 | # Find a document from the collection of 'User' by its id.
83 | findUserByID(
84 | # The 'User' document's ID
85 | id: ID!
86 | ): User
87 | # Find a document from the collection of 'Thing' by its id.
88 | findThingByID(
89 | # The 'Thing' document's ID
90 | id: ID!
91 | ): Thing
92 | findAllThings(
93 | # The number of items to return per page.
94 | _size: Int
95 | # The pagination cursor.
96 | _cursor: String
97 | ): ThingPage!
98 | }
99 |
100 | type Thing {
101 | # The document's ID.
102 | _id: ID!
103 | # The document's timestamp.
104 | _ts: Long!
105 | owner: User!
106 | name: String
107 | }
108 |
109 | # 'Thing' input values
110 | input ThingInput {
111 | owner: ThingOwnerRelation
112 | name: String
113 | }
114 |
115 | # Allow manipulating the relationship between the types 'Thing' and 'User' using the field 'Thing.owner'.
116 | input ThingOwnerRelation {
117 | # Create a document of type 'User' and associate it with the current document.
118 | create: UserInput
119 | # Connect a document of type 'User' with the current document using its ID.
120 | connect: ID
121 | }
122 |
123 | # The pagination object for elements of type 'Thing'.
124 | type ThingPage {
125 | # The elements of type 'Thing' in this page.
126 | data: [Thing]!
127 | # A cursor for elements coming after the current page.
128 | after: String
129 | # A cursor for elements coming before the current page.
130 | before: String
131 | }
132 |
133 | scalar Time
134 |
135 | type User {
136 | email: String!
137 | role: UserRole!
138 | # The document's ID.
139 | _id: ID!
140 | things(
141 | # The number of items to return per page.
142 | _size: Int
143 | # The pagination cursor.
144 | _cursor: String
145 | ): ThingPage!
146 | # The document's timestamp.
147 | _ts: Long!
148 | }
149 |
150 | # 'User' input values
151 | input UserInput {
152 | things: UserThingsRelation
153 | email: String!
154 | role: UserRole!
155 | }
156 |
157 | enum UserRole {
158 | FREE_USER
159 | }
160 |
161 | # Allow manipulating the relationship between the types 'User' and 'Thing'.
162 | input UserThingsRelation {
163 | # Create one or more documents of type 'Thing' and associate them with the current document.
164 | create: [ThingInput]
165 | # Connect one or more documents of type 'Thing' with the current document using their IDs.
166 | connect: [ID]
167 | # Disconnect the given documents of type 'Thing' from the current document using their IDs.
168 | disconnect: [ID]
169 | }
170 | `;
171 |
172 | export { remoteTypeDefs };
173 |
--------------------------------------------------------------------------------
/lib/graphql/schema.js:
--------------------------------------------------------------------------------
1 | import {
2 | mergeSchemas,
3 | makeExecutableSchema,
4 | makeRemoteExecutableSchema,
5 | addSchemaLevelResolveFunction,
6 | } from 'apollo-server-micro';
7 | import { setContext } from 'apollo-link-context';
8 | import { createHttpLink } from 'apollo-link-http';
9 | import cookie from 'cookie';
10 | import chalk from 'chalk';
11 |
12 | import { remoteTypeDefs } from './remoteSchema';
13 | import { localTypeDefs, localResolvers } from './localSchema';
14 | import { createOverrideResolvers } from './overrideSchema';
15 | import { SECRET_COOKIE_NAME } from '../cookieConfig';
16 |
17 | /* We create the link from scratch because we need to use
18 | `concat` later on */
19 | const httpLink = new createHttpLink({
20 | uri: 'https://graphql.fauna.com/graphql',
21 | fetch,
22 | });
23 |
24 | /* `setContext` runs before any remote request by `delegateToSchema`,
25 | this is due to `contextlink.concat`.
26 | In other words, it runs before delegating to Fauna.
27 | In general, this function is in charge of deciding which token to use
28 | in the headers, the public one or the one from the user. For example,
29 | during login or signup it will always default to the public token
30 | because it will not find any token in the headers from `previousContext` */
31 | const contextlink = setContext((_, previousContext) => {
32 | console.log(chalk.gray('⚙️ ') + chalk.cyan('schema -- setContext'));
33 | let token = process.env.FAUNADB_PUBLIC_ACCESS_KEY; // public token
34 | const { req } = previousContext.graphqlContext;
35 | if (!req.headers.cookie)
36 | console.log(
37 | ' schema -- setContext -- Setting headers with default public token.'
38 | );
39 | if (req.headers.cookie) {
40 | const parsedCookie = cookie.parse(req.headers.cookie);
41 | const customCookie = parsedCookie[SECRET_COOKIE_NAME];
42 | if (customCookie) {
43 | console.log(
44 | ' schema -- setContext -- Found custom cookie. Re-setting headers with it.'
45 | );
46 | token = customCookie;
47 | }
48 | }
49 | /* `token` is the public one always, except for when
50 | we find a `customCookie` */
51 | return {
52 | headers: {
53 | Authorization: `Bearer ${token}`,
54 | },
55 | };
56 | });
57 |
58 | /* Then we finally create the link to use to handle the remote schemas */
59 | const link = contextlink.concat(httpLink);
60 |
61 | /* We'll not be using `introspectSchema` in order to avoid
62 | a second trip to the Fauna server.
63 | The remote schema was downloaded directly from Fauna
64 | and saved to local file (remoteTypeDefs). */
65 | const remoteExecutableSchema = makeRemoteExecutableSchema({
66 | schema: remoteTypeDefs,
67 | link,
68 | });
69 |
70 | /* `localExecutableSchema` is used to implement functionality
71 | exclusive only to the client. */
72 | const localExecutableSchema = makeExecutableSchema({
73 | typeDefs: localTypeDefs,
74 | resolvers: localResolvers,
75 | });
76 |
77 | const schema = mergeSchemas({
78 | schemas: [remoteExecutableSchema, localExecutableSchema],
79 | /* `createOverrideResolvers` helps, as it names implies,
80 | to override UDFs present in Fauna Graphql endpoint.
81 | These overrides will run before hitting Fauna's servers.
82 | Refer back to setContext for the function that sets
83 | the headers before connecting to Fauna. */
84 | resolvers: createOverrideResolvers(
85 | remoteExecutableSchema,
86 | localExecutableSchema
87 | ),
88 | });
89 |
90 | /* Runs after `context` and before `setContext */
91 | const rootResolveFunction = async (parent, args, context, info) => {
92 | console.log(
93 | chalk.yellow('⚡️ ') +
94 | chalk.cyan(
95 | 'root resolver -- to run:',
96 | `${info.path.typename} ${info.path.key}`
97 | )
98 | );
99 | };
100 |
101 | addSchemaLevelResolveFunction(schema, rootResolveFunction);
102 |
103 | export default schema;
104 |
--------------------------------------------------------------------------------
/lib/graphqlHelper.js:
--------------------------------------------------------------------------------
1 | import faunadb from 'faunadb';
2 |
3 | import { SECRET_COOKIE_NAME, unsetCookieConfig } from './cookieConfig';
4 |
5 | // Used for any authed requests.
6 | export const faunaClient = (secret) =>
7 | new faunadb.Client({
8 | secret,
9 | });
10 |
11 | export const delegate = (
12 | args,
13 | context,
14 | info,
15 | schemaObj,
16 | operation,
17 | fieldName
18 | ) => {
19 | const { cookie: setCookie } = context.res;
20 | // To get the schema var name as a string to be used in the console log
21 | // in case of error
22 | const varToString = (varObj) => Object.keys(varObj)[0];
23 | return info.mergeInfo
24 | .delegateToSchema({
25 | schema: schemaObj[Object.keys(schemaObj)[0]],
26 | operation,
27 | fieldName,
28 | args,
29 | context,
30 | info,
31 | })
32 | .catch((error) => {
33 | if (fieldName !== 'logoutUser') {
34 | console.log(
35 | `${operation} (${fieldName}) - Delegation to ${varToString(
36 | schemaObj
37 | )} failed --`,
38 | error.message
39 | );
40 | return error;
41 | } else {
42 | if (error.message === 'Invalid database secret.') {
43 | console.log(
44 | ' mutation logoutUser -- Already logged out -- Deleting cookie'
45 | );
46 | setCookie(SECRET_COOKIE_NAME, '', unsetCookieConfig);
47 | return 'already logged out';
48 | }
49 | if (
50 | error.message === 'Insufficient privileges to perform the action.'
51 | ) {
52 | console.log(
53 | ' mutation logoutUser -- Already logged out and using public token'
54 | );
55 | return 'already logged out';
56 | }
57 | console.log(
58 | " mutation logoutUser -- Couldn't log user out --",
59 | error.message
60 | );
61 | return error.message;
62 | }
63 | });
64 | };
65 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "with-cookie-auth-fauna-apollo-server",
3 | "version": "1.0.0",
4 | "scripts": {
5 | "dev": "next",
6 | "build": "next build",
7 | "start": "next start"
8 | },
9 | "dependencies": {
10 | "apollo-link-context": "^1.0.20",
11 | "apollo-link-http": "^1.5.17",
12 | "apollo-server-micro": "^2.18.2",
13 | "cookie": "^0.4.1",
14 | "faunadb": "^3.0.1",
15 | "graphql": "^15.3.0",
16 | "graphql-request": "^3.1.0",
17 | "next": "9.5.5",
18 | "react": "^16.14.0",
19 | "react-dom": "^16.14.0",
20 | "react-query": "^2.23.1",
21 | "react-query-devtools": "^2.6.0"
22 | },
23 | "devDependencies": {
24 | "babel-plugin-graphql-tag": "^3.1.0",
25 | "dotenv": "^8.2.0",
26 | "eslint": "^7.11.0",
27 | "eslint-plugin-react": "^7.21.4",
28 | "request": "^2.88.2"
29 | }
30 | }
31 |
--------------------------------------------------------------------------------
/pages/_app.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { ReactQueryDevtools } from 'react-query-devtools';
3 | import { UserContextProvider } from '../components/UserContext';
4 |
5 | function MyApp({ Component, pageProps }) {
6 | return (
7 |
8 |
9 |
10 |
11 | );
12 | }
13 |
14 | export default MyApp;
15 |
--------------------------------------------------------------------------------
/pages/api/graphql.js:
--------------------------------------------------------------------------------
1 | import { ApolloServer } from 'apollo-server-micro';
2 | import cookie from 'cookie';
3 | import faunadb from 'faunadb';
4 | import chalk from 'chalk';
5 |
6 | import cookieSetter from '../../lib/cookieHelper';
7 | import { SECRET_COOKIE_NAME, unsetCookieConfig } from '../../lib/cookieConfig';
8 | import { faunaClient } from '../../lib/graphqlHelper';
9 | import schema from '../../lib/graphql/schema';
10 |
11 | const q = faunadb.query;
12 |
13 | const tokenValidation = async (req, res) => {
14 | /* Since we need to constantly validate the user token
15 | then it makes sense to do it before every resolver, and
16 | save either an empty string or a valid token in the context.
17 | `tokenValidation` only unsets the cookie in case it's not valid. */
18 | console.log(chalk.gray('⚙️ ') + chalk.cyan('context -- tokenValidation'));
19 | const { setCookie } = res;
20 | let isTokenValid, token;
21 | if (req.headers.cookie) {
22 | const parsedCookies = cookie.parse(req.headers.cookie);
23 | const customCookie = parsedCookies[SECRET_COOKIE_NAME];
24 | if (customCookie) {
25 | try {
26 | isTokenValid = await faunaClient(customCookie).query(
27 | q.Call(q.Function('validate_token'), customCookie)
28 | );
29 | if (isTokenValid === true) {
30 | token = customCookie;
31 | console.log(
32 | ' context -- tokenValidation --',
33 | chalk.green('token is valid!')
34 | );
35 | /* Don't reset the cookie with `setCookie`, as it would restart its maxAge time.
36 | Setting up the cookie should only be done on login or signup. */
37 | } else {
38 | token = '';
39 | setCookie(SECRET_COOKIE_NAME, '', unsetCookieConfig);
40 | }
41 | } catch (err) {
42 | console.log(
43 | chalk.red(' context -- tokenValidation failed, clearing cookie'),
44 | err.message
45 | );
46 | token = '';
47 | setCookie(SECRET_COOKIE_NAME, '', unsetCookieConfig);
48 | }
49 | }
50 | if (!customCookie) {
51 | console.log(chalk.red(' context -- tokenValidation, no cookie found'));
52 | token = '';
53 | }
54 | }
55 | return token;
56 | };
57 |
58 | const apolloServer = new ApolloServer({
59 | schema,
60 | /* The context is recalculated every time a resolver runs,
61 | it runs even before `setContext`. We can also pass it down
62 | to all resolvers and resolve it there */
63 | context: async (ctx) => ({
64 | token: tokenValidation,
65 | ...ctx,
66 | }),
67 | introspection: !(process.env.NODE_ENV === 'production'),
68 | playground: !(process.env.NODE_ENV === 'production'),
69 | });
70 |
71 | export const config = {
72 | api: {
73 | bodyParser: false,
74 | },
75 | };
76 |
77 | const handler = apolloServer.createHandler({ path: '/api/graphql' });
78 |
79 | export default cookieSetter(handler);
80 |
--------------------------------------------------------------------------------
/pages/index.js:
--------------------------------------------------------------------------------
1 | import React, { useEffect } from 'react';
2 | import { useMutation, useQuery, queryCache } from 'react-query';
3 | import { request } from 'graphql-request';
4 |
5 | import App from '../components/App';
6 | import InfoBox from '../components/InfoBox';
7 | import SignUp from '../components/SignUp';
8 | import LogIn from '../components/LogIn';
9 | import ThingList from '../components/ThingsList';
10 | import { user } from '../components/UserContext';
11 |
12 | const LOGOUT_USER = `
13 | mutation logoutUser {
14 | logoutUser
15 | }
16 | `;
17 |
18 | const VALIDATE_COOKIE = `
19 | query validateCookie {
20 | validCookie
21 | }
22 | `;
23 |
24 | const IndexPage = () => {
25 | const { id, setId } = user();
26 |
27 | const [logoutUser, { status: logoutStatus }] = useMutation(
28 | () => request('/api/graphql', LOGOUT_USER),
29 | {
30 | onSuccess: () => {
31 | queryCache.clear();
32 | localStorage.removeItem('userId');
33 | setId('');
34 | console.log('Logout success');
35 | },
36 | }
37 | );
38 |
39 | // Should only validate when user is logged in and every 3 seconds
40 | const { status: validateStatus, isFetching: isValidateFetching } = useQuery(
41 | ['validCookie'],
42 | async () => {
43 | // debugger;
44 | return request('/api/graphql', VALIDATE_COOKIE);
45 | },
46 | {
47 | onSuccess: (data) => {
48 | if (data.validCookie === true) {
49 | /* No need to do anything else, the `userId` is handled in the `useEffect` */
50 | console.log('Validation success');
51 | } else {
52 | console.log('Custom cookie not valid');
53 | logoutUser();
54 | }
55 | },
56 | onError: (err) => {
57 | console.log(err);
58 | },
59 | refetchOnMount: false,
60 | refetchOnWindowFocus: true,
61 | }
62 | );
63 |
64 | /* Only runs once to pull the id from `localStorage` */
65 | useEffect(() => {
66 | const userId = localStorage.getItem('userId');
67 | userId ? setId(userId) : setId('');
68 | }, []);
69 |
70 | return (
71 |
72 |
73 | This example shows how to signup/login and setup an httpOnly cookie
74 | while also validating said cookie on focus and on every initial render.
75 |
76 |
77 | Try duplicating the tab, logging out in the new one, and then navigating
78 | back to the original. It should automatically logout, syncing both tabs.
79 |
80 | Lookout for "custom_cookie" in the devtools
81 |
82 | Try to log in with:
83 |
84 | email: 123@example.com
85 |
86 | password: 123
87 |
88 |
89 | Is cookie being validated?{' '}
90 |
91 | {validateStatus === 'loading' || isValidateFetching
92 | ? 'TRUE'
93 | : 'FALSE'}
94 |
95 |
96 |
97 | Is user logged in? {id ? 'TRUE' : 'FALSE'}
98 |
99 | {!id ? null : (
100 |
101 |
LogOut
102 |
108 | Submit
109 |
110 |
111 |
112 | )}
113 | {id ? null : (
114 | <>
115 |
116 |
117 | >
118 | )}
119 |
130 |
131 | );
132 | };
133 |
134 | export default IndexPage;
135 |
--------------------------------------------------------------------------------
/scripts/config.js:
--------------------------------------------------------------------------------
1 | require('dotenv').config({ path: '.env.local' });
2 | const { Client } = require('faunadb');
3 | const chalk = require('chalk');
4 |
5 | const ENV_NAME = 'FAUNADB_ADMIN_SECRET';
6 |
7 | function validateThenRun(fnc) {
8 | if (!process.env[ENV_NAME]) {
9 | console.log(
10 | chalk.yellow(`Required ${ENV_NAME} enviroment variable not found.`)
11 | );
12 | process.exit(1);
13 | }
14 |
15 | if (process.env[ENV_NAME]) {
16 | return fnc();
17 | }
18 | }
19 |
20 | function createClient() {
21 | const faunaClient = new Client({
22 | secret: process.env[ENV_NAME],
23 | });
24 | console.log(chalk.gray('\n🛠 ') + ` Fauna client created`);
25 | return faunaClient;
26 | }
27 |
28 | // Helpers
29 | const createThen = (typeName) => (r) => {
30 | console.log(chalk.green('✅') + ` Created ${typeName}`);
31 | return r;
32 | };
33 |
34 | const createCatch = (typeName) => (e) => {
35 | try {
36 | // console.log(e);
37 | if (e.message === 'instance already exists') {
38 | console.log(
39 | chalk.yellow('⏭ ') + ` ${typeName} already exists. Skipping...`
40 | );
41 | } else if (e.description === 'Unauthorized') {
42 | e.description =
43 | 'Unauthorized: missing or invalid FAUNADB_ADMIN_SECRET, or not enough permissions';
44 | throw e;
45 | } else if (
46 | e.description === 'Insufficient privileges to perform the action.'
47 | ) {
48 | e.description =
49 | 'Insufficient privileges to perform the action. Check you are using an admin key instead of a server one';
50 | throw e;
51 | } else if (e.description === 'document is not unique.') {
52 | e.description = `${typeName} already exists`;
53 | throw e;
54 | } else {
55 | throw e;
56 | }
57 | } catch (e) {
58 | console.log(chalk.green('⛔️ ') + (e.description || e.message));
59 | }
60 | };
61 |
62 | const updateThen = (typeName) => (r) => {
63 | console.log(chalk.blue('✅ ') + `Updated ${typeName}`);
64 | return r;
65 | };
66 |
67 | const updateCatch = (e) => {
68 | if (e) {
69 | if (e.message === 'unauthorized') {
70 | e.message =
71 | 'unauthorized: missing or invalid fauna_server_secret, or not enough permissions';
72 | throw e;
73 | } else {
74 | throw e;
75 | }
76 | }
77 | };
78 |
79 | module.exports = {
80 | ENV_NAME: ENV_NAME,
81 | faunaClient: validateThenRun(createClient),
82 | createClient: createClient,
83 | validateThenRun: validateThenRun,
84 | updateCatch: updateCatch,
85 | updateThen: updateThen,
86 | createCatch: createCatch,
87 | createThen: createThen,
88 | };
89 |
--------------------------------------------------------------------------------
/scripts/createDoc.js:
--------------------------------------------------------------------------------
1 | const { query: q } = require('faunadb');
2 | const chalk = require('chalk');
3 |
4 | const { faunaClient, createThen, createCatch } = require('./config');
5 |
6 | // The following is an example of how to create the first user in the db
7 | // directly with the javascript driver
8 |
9 | const createUser = async () => {
10 | console.log(chalk.yellow('\n⚡️ ') + chalk.cyan('Creating user\n'));
11 | return faunaClient
12 | .query(
13 | q.Create(q.Collection('User'), {
14 | data: {
15 | email: '123@example.com',
16 | role: 'FREE_USER',
17 | },
18 | credentials: {
19 | password: '123',
20 | },
21 | })
22 | )
23 | .then(createThen('Document "User"'))
24 | .catch(createCatch('Document "User"'));
25 | };
26 |
27 | module.exports.createUser = createUser;
28 |
--------------------------------------------------------------------------------
/scripts/createRoles.js:
--------------------------------------------------------------------------------
1 | const { query: q } = require('faunadb');
2 | const chalk = require('chalk');
3 |
4 | const { faunaClient, createThen, createCatch } = require('./config');
5 |
6 | // This file contains the config for all the roles
7 | // Notice we are also creating here a function (crFnc1) that uses a previously created role (crRol5)
8 |
9 | const crRol0 = () => {
10 | return console.log(chalk.yellow('\n⚡️ ') + chalk.cyan('Creating roles\n'));
11 | };
12 |
13 | const crRol1 = async () => {
14 | return faunaClient
15 | .query(
16 | q.CreateRole({
17 | name: 'fnc_role_create_user',
18 | privileges: [
19 | {
20 | resource: q.Collection('User'),
21 | actions: {
22 | read: true,
23 | create: q.Query(
24 | q.Lambda(
25 | 'values',
26 | q.Equals(
27 | q.Select(['data', 'role'], q.Var('values')),
28 | 'FREE_USER'
29 | )
30 | )
31 | ),
32 | },
33 | },
34 | ],
35 | })
36 | )
37 | .then(createThen(`Role "fnc_role_create_user"`))
38 | .catch(createCatch(`Role "fnc_role_create_user"`));
39 | };
40 |
41 | const crRol2 = async () => {
42 | return faunaClient
43 | .query(
44 | q.CreateRole({
45 | name: 'fnc_role_login_user',
46 | privileges: [
47 | {
48 | resource: q.Index('unique_User_email'),
49 | actions: {
50 | unrestricted_read: true,
51 | },
52 | },
53 | {
54 | resource: q.Collection('User'),
55 | actions: {
56 | read: true,
57 | },
58 | },
59 | ],
60 | })
61 | )
62 | .then(createThen(`Role "fnc_role_login_user"`))
63 | .catch(createCatch(`Role "fnc_role_login_user"`));
64 | };
65 |
66 | const crRol3 = async () => {
67 | return faunaClient
68 | .query(
69 | q.CreateRole({
70 | name: 'fnc_role_logout_user',
71 | privileges: [
72 | {
73 | resource: q.Ref('tokens'),
74 | actions: {
75 | create: true,
76 | read: true,
77 | },
78 | },
79 | ],
80 | })
81 | )
82 | .then(createThen(`Role "fnc_role_logout_user"`))
83 | .catch(createCatch(`Role "fnc_role_logout_user"`));
84 | };
85 |
86 | const crRol4 = async () => {
87 | return faunaClient
88 | .query(
89 | q.CreateRole({
90 | name: 'fnc_role_signup_user',
91 | privileges: [
92 | {
93 | resource: q.Function('create_user'),
94 | actions: {
95 | call: true,
96 | },
97 | },
98 | {
99 | resource: q.Function('login_user'),
100 | actions: {
101 | call: true,
102 | },
103 | },
104 | ],
105 | })
106 | )
107 | .then(createThen(`Role "fnc_role_signup_user"`))
108 | .catch(createCatch(`Role "fnc_role_signup_user"`));
109 | };
110 |
111 | const crRol5 = async () => {
112 | return faunaClient
113 | .query(
114 | q.CreateRole({
115 | name: 'fnc_role_validate_token',
116 | privileges: [
117 | {
118 | resource: q.Ref('tokens'),
119 | actions: {
120 | read: true,
121 | },
122 | },
123 | ],
124 | })
125 | )
126 | .then(createThen(`Role "fnc_role_validate_token"`))
127 | .catch(createCatch(`Role "fnc_role_validate_token"`));
128 | };
129 |
130 | const crFnc1 = async () => {
131 | return faunaClient
132 | .query(
133 | q.CreateFunction({
134 | name: 'validate_token',
135 | body: q.Query(
136 | q.Lambda(
137 | '_',
138 | q.Abort('Function validate_token is not implemented yet.')
139 | )
140 | ),
141 | })
142 | )
143 | .then(createThen(`Function "validate_token"`))
144 | .catch(createCatch(`Function "validate_token"`));
145 | };
146 |
147 | const crRol6 = async () => {
148 | return faunaClient
149 | .query(
150 | q.CreateRole({
151 | name: 'free_user',
152 | privileges: [
153 | {
154 | resource: q.Collection('User'),
155 | actions: {
156 | read: q.Query(
157 | q.Lambda('userRef', q.Equals(q.Identity(), q.Var('userRef')))
158 | ),
159 | write: q.Query(
160 | q.Lambda(
161 | ['_', 'newData', 'userRef'],
162 | q.And(
163 | q.Equals(q.Identity(), q.Var('userRef')),
164 | q.Equals(
165 | 'FREE_USER',
166 | q.Select(['data', 'role'], q.Var('newData'))
167 | )
168 | )
169 | )
170 | ),
171 | },
172 | },
173 | {
174 | // This is restricting the actions the User can do with Thing
175 | // Basically, making sure each user can only see, create, modify or delete his or her stuff
176 | resource: q.Collection('Thing'),
177 | actions: {
178 | read: q.Query(
179 | q.Lambda(
180 | 'ref',
181 | q.Equals(
182 | q.Identity(),
183 | q.Select(['data', 'owner'], q.Get(q.Var('ref')))
184 | )
185 | )
186 | ),
187 | write: q.Query(
188 | q.Lambda(
189 | ['oldData', 'newData'],
190 | q.And(
191 | q.Equals(
192 | q.Identity(),
193 | q.Select(['data', 'owner'], q.Var('oldData'))
194 | ),
195 | q.Equals(
196 | q.Select(['data', 'owner'], q.Var('oldData')),
197 | q.Select(['data', 'owner'], q.Var('newData'))
198 | )
199 | )
200 | )
201 | ),
202 | create: q.Query(
203 | q.Lambda(
204 | 'values',
205 | q.Equals(
206 | q.Identity(),
207 | q.Select(['data', 'owner'], q.Var('values'))
208 | )
209 | )
210 | ),
211 | delete: q.Query(
212 | q.Lambda(
213 | 'ref',
214 | q.Equals(
215 | q.Identity(),
216 | q.Select(['data', 'owner'], q.Get(q.Var('ref')))
217 | )
218 | )
219 | ),
220 | },
221 | },
222 | {
223 | resource: q.Function('validate_token'),
224 | actions: {
225 | call: true,
226 | },
227 | },
228 | {
229 | resource: q.Function('logout_user'),
230 | actions: {
231 | call: true,
232 | },
233 | },
234 | {
235 | resource: q.Index('findAllThings'),
236 | actions: {
237 | unrestricted_read: false,
238 | read: true,
239 | },
240 | },
241 | ],
242 | membership: [
243 | {
244 | resource: q.Collection('User'),
245 | predicate: q.Query(
246 | q.Lambda(
247 | 'userRef',
248 | q.Or(
249 | q.Equals(
250 | q.Select(['data', 'role'], q.Get(q.Var('userRef'))),
251 | 'FREE_USER'
252 | )
253 | )
254 | )
255 | ),
256 | },
257 | ],
258 | })
259 | )
260 | .then(createThen(`Role "free_user"`))
261 | .catch(createCatch(`Role "free_user"`));
262 | };
263 |
264 | const crRol7 = async () => {
265 | return faunaClient
266 | .query(
267 | q.CreateRole({
268 | name: 'public',
269 | privileges: [
270 | {
271 | resource: q.Function('signup_user'),
272 | actions: {
273 | call: true,
274 | },
275 | },
276 | {
277 | resource: q.Function('login_user'),
278 | actions: {
279 | call: true,
280 | },
281 | },
282 | ],
283 | })
284 | )
285 | .then(createThen(`Role "public"`))
286 | .catch(createCatch(`Role "public"`));
287 | };
288 |
289 | const fnList = [
290 | crRol0,
291 | crRol1,
292 | crRol2,
293 | crRol3,
294 | crRol4,
295 | crRol5,
296 | crFnc1,
297 | crRol6,
298 | crRol7,
299 | ];
300 |
301 | module.exports.roleFnList = fnList;
302 |
--------------------------------------------------------------------------------
/scripts/faunaSchema.graphql:
--------------------------------------------------------------------------------
1 | enum UserRole {
2 | FREE_USER
3 | }
4 |
5 | # If creating one-to-one relationships, take this into account
6 | # https://forums.fauna.com/t/in-a-one-to-one-relation-between-two-collections-provide-a-mechanism-to-control-in-what-collection-the-reference-will-be-stored/36
7 | # In this example we'll be using a one-to-many relationship
8 | # https://docs.fauna.com/fauna/current/api/graphql/relations#one2many
9 | # A user can own many things, and a thing can be owned by one user (for the example's sake)
10 |
11 | type User {
12 | things: [Thing] @relation
13 | email: String! @unique
14 | role: UserRole!
15 | }
16 |
17 | type Thing {
18 | owner: User!
19 | name: String
20 | }
21 |
22 | input CreateUserInput {
23 | email: String!
24 | password: String!
25 | role: UserRole!
26 | }
27 |
28 | input LoginUserInput {
29 | email: String!
30 | password: String!
31 | }
32 |
33 | type LoginRes @embedded {
34 | userToken: String
35 | userId: String!
36 | }
37 |
38 | type Query {
39 | findAllThings: [Thing]
40 | }
41 |
42 | type Mutation {
43 | signupUser(data: CreateUserInput!): LoginRes! @resolver(name: "signup_user")
44 | createUser(data: CreateUserInput!): User! @resolver(name: "create_user")
45 | loginUser(data: LoginUserInput!): LoginRes! @resolver(name: "login_user")
46 | logoutUser: Boolean! @resolver(name: "logout_user")
47 | }
48 |
--------------------------------------------------------------------------------
/scripts/manageKeys.js:
--------------------------------------------------------------------------------
1 | const { query: q } = require('faunadb');
2 | const chalk = require('chalk');
3 |
4 | const { faunaClient, createThen, createCatch } = require('./config');
5 |
6 | async function manageKeys() {
7 | console.log(chalk.yellow('\n⚡️ ') + chalk.cyan('Manage keys\n'));
8 | const publicKey = (
9 | await faunaClient
10 | .query(
11 | q.CreateKey({
12 | name: `public_key`,
13 | role: q.Role('public'),
14 | })
15 | )
16 | .then(createThen(`Key "public_key"`))
17 | .catch(createCatch(`Key "public_key"`))
18 | ).secret;
19 | console.log(
20 | chalk.yellow('\n🚀 ') +
21 | 'Save this public client key in the .env file:\n\n' +
22 | ' FAUNADB_PUBLIC_ACCESS_KEY=' +
23 | chalk.yellow(publicKey)
24 | );
25 | }
26 |
27 | module.exports.manageKeys = manageKeys;
28 |
--------------------------------------------------------------------------------
/scripts/setup.js:
--------------------------------------------------------------------------------
1 | const { uploadSchema } = require('./uploadSchema');
2 | const { fnList } = require('./updateFunctions');
3 | const { roleFnList } = require('./createRoles');
4 | const { manageKeys } = require('./manageKeys');
5 | const { createUser } = require('./createDoc');
6 |
7 | const fullFnList = [
8 | uploadSchema,
9 | ...roleFnList,
10 | ...fnList,
11 | createUser,
12 | manageKeys,
13 | ];
14 |
15 | // Inspired by the script on ptpaterson's example
16 | // https://github.com/ptpaterson/netlify-faunadb-graphql-auth/tree/master/scripts
17 |
18 | module.exports.full = async function full() {
19 | for (const fn of fullFnList) {
20 | await fn();
21 | }
22 | };
23 |
24 | module.exports.updateFunctions = async function updateFunctions() {
25 | for (const fn of fnList) {
26 | await fn();
27 | }
28 | };
29 |
30 | module.exports.createRoles = async function createRoles() {
31 | for (const fn of roleFnList) {
32 | await fn();
33 | }
34 | };
35 |
36 | module.exports.manageKeys = manageKeys;
37 |
38 | module.exports.uploadSchema = uploadSchema;
39 |
40 | module.exports.createUser = createUser;
41 |
42 | // Run:
43 | // node -e 'require("./scripts/setup.js").full()'
44 | // to run the full setup script
45 | // Alternatevely you can run each function by it self
46 | // Similar to:
47 | // node -e 'require("./scripts/setup.js").updateFunctions()'
48 | // or
49 | // node -e 'require("./scripts/setup.js").uploadSchema()'
50 |
--------------------------------------------------------------------------------
/scripts/updateFunctions.js:
--------------------------------------------------------------------------------
1 | const { query: q } = require('faunadb');
2 | const chalk = require('chalk');
3 |
4 | const { faunaClient, updateThen, updateCatch } = require('./config');
5 |
6 | const upFn0 = () => {
7 | return console.log(
8 | chalk.yellow('\n⚡️ ') + chalk.cyan('Updating functions\n')
9 | );
10 | };
11 |
12 | const upFn1 = async () => {
13 | return faunaClient
14 | .query(
15 | q.Update(q.Function('create_user'), {
16 | role: q.Role('fnc_role_create_user'),
17 | body: q.Query(
18 | q.Lambda(
19 | ['input'],
20 | q.Create(q.Collection('User'), {
21 | data: {
22 | email: q.Select('email', q.Var('input')),
23 | role: q.Select('role', q.Var('input')),
24 | },
25 | credentials: { password: q.Select('password', q.Var('input')) },
26 | })
27 | )
28 | ),
29 | })
30 | )
31 | .then(updateThen('Function "create_user"'))
32 | .catch(updateCatch);
33 | };
34 |
35 | const upFn2 = async () => {
36 | return faunaClient
37 | .query(
38 | q.Update(q.Function('login_user'), {
39 | role: q.Role('fnc_role_login_user'),
40 | body: q.Query(
41 | q.Lambda(['input'], {
42 | userToken: q.Select(
43 | ['secret'],
44 | q.Login(
45 | q.Select(
46 | ['ref'],
47 | q.Get(
48 | q.Match(
49 | q.Index('unique_User_email'),
50 | q.Select('email', q.Var('input'))
51 | )
52 | )
53 | ),
54 | {
55 | password: q.Select('password', q.Var('input')),
56 | ttl: q.TimeAdd(q.Now(), 14, 'days'),
57 | }
58 | )
59 | ),
60 | userId: q.Select(
61 | ['ref', 'id'],
62 | q.Get(
63 | q.Match(
64 | q.Index('unique_User_email'),
65 | q.Select('email', q.Var('input'))
66 | )
67 | )
68 | ),
69 | })
70 | ),
71 | })
72 | )
73 | .then(updateThen('Function "login_user"'))
74 | .catch(updateCatch);
75 | };
76 |
77 | const upFn3 = async () => {
78 | return faunaClient
79 | .query(
80 | q.Update(q.Function('logout_user'), {
81 | role: q.Role('fnc_role_logout_user'),
82 | body: q.Query(q.Lambda('_', q.Logout(true))),
83 | })
84 | )
85 | .then(updateThen('Function "logout_user"'))
86 | .catch(updateCatch);
87 | };
88 |
89 | const upFn4 = async () => {
90 | return faunaClient
91 | .query(
92 | q.Update(q.Function('signup_user'), {
93 | role: q.Role('fnc_role_signup_user'),
94 | body: q.Query(
95 | q.Lambda(
96 | ['input'],
97 | q.Do(
98 | q.Call(q.Function('create_user'), q.Var('input')),
99 | q.Call(q.Function('login_user'), q.Var('input'))
100 | )
101 | )
102 | ),
103 | })
104 | )
105 | .then(updateThen('Function "signup_user"'))
106 | .catch(updateCatch);
107 | };
108 |
109 | const upFn5 = async () => {
110 | return faunaClient
111 | .query(
112 | q.Update(q.Function('validate_token'), {
113 | role: q.Role('fnc_role_validate_token'),
114 | body: q.Query(
115 | q.Lambda(['token'], q.Not(q.IsNull(q.KeyFromSecret(q.Var('token')))))
116 | ),
117 | })
118 | )
119 | .then(updateThen('Function "validate_token"'))
120 | .catch(updateCatch);
121 | };
122 |
123 | const fnList = [upFn0, upFn1, upFn2, upFn3, upFn4, upFn5];
124 |
125 | module.exports.fnList = fnList;
126 |
--------------------------------------------------------------------------------
/scripts/uploadSchema.js:
--------------------------------------------------------------------------------
1 | require('dotenv').config({ path: '.env.local' });
2 | const request = require('request');
3 | const fs = require('fs');
4 | const path = require('path');
5 | const chalk = require('chalk');
6 |
7 | const { ENV_NAME } = require('./config');
8 |
9 | async function uploadSchema() {
10 | await new Promise((resolve, reject) => {
11 | console.log(
12 | chalk.yellow('\n⚡️ ') + chalk.cyan('Uploading Graphql Schema...\n')
13 | );
14 | fs.createReadStream(path.join(__dirname, 'faunaSchema.graphql')).pipe(
15 | request.post(
16 | {
17 | type: 'application/octet-stream',
18 | headers: {
19 | Authorization: `Bearer ${process.env[ENV_NAME]}`,
20 | },
21 | url: 'https://graphql.fauna.com/import',
22 | },
23 | (err, res, body) => {
24 | if (err) reject(err);
25 | resolve(body);
26 | }
27 | )
28 | );
29 | })
30 | .then(() => console.log(chalk.blue('✅ ') + `GraphQL schema imported`))
31 | .catch((error) => {
32 | console.log(chalk.red('⛔️ ') + ` Error during schema import`);
33 | console.log(error);
34 | process.exit(1);
35 | });
36 | }
37 |
38 | module.exports.uploadSchema = uploadSchema;
39 |
--------------------------------------------------------------------------------