wasp/web/docs/auth/overview.md
2023-12-13 17:52:55 +01:00

34 KiB

title
Using Auth

import { AuthMethodsGrid } from "@site/src/components/AuthMethodsGrid"; import { Required } from "@site/src/components/Required";

Auth is an essential piece of any serious application. Coincidentally, Wasp provides authentication and authorization support out of the box.

Here's a 1-minute tour of how full-stack auth works in Wasp:

Enabling auth for your app is optional and can be done by configuring the auth field of the app declaration.

app MyApp {
  title: "My app",
  //...
  auth: {
    userEntity: User,
    externalAuthEntity: SocialLogin,
    methods: {
      usernameAndPassword: {}, // use this or email, not both
      email: {}, // use this or usernameAndPassword, not both
      google: {},
      gitHub: {},
    },
    onAuthFailedRedirectTo: "/someRoute"
  }
}

//...
app MyApp {
  title: "My app",
  //...
  auth: {
    userEntity: User,
    externalAuthEntity: SocialLogin,
    methods: {
      usernameAndPassword: {}, // use this or email, not both
      email: {}, // use this or usernameAndPassword, not both
      google: {},
      gitHub: {},
    },
    onAuthFailedRedirectTo: "/someRoute"
  }
}

//...

Read more about the auth field options in the API Reference section.

We will provide a quick overview of auth in Wasp and link to more detailed documentation for each auth method.

Available auth methods

Wasp supports the following auth methods:

Let's say we enabled the Username & password authentication.

We get an auth backend with signup and login endpoints. We also get the user object in our Operations and we can decide what to do based on whether the user is logged in or not.

We would also get the Auth UI generated for us. We can set up our login and signup pages where our users can create their account and login. We can then protect certain pages by setting authRequired: true for them. This will make sure that only logged-in users can access them.

We will also have access to the user object in our frontend code, so we can show different UI to logged-in and logged-out users. For example, we can show the user's name in the header alongside a logout button or a login button if the user is not logged in.

Protecting a page with authRequired

When declaring a page, you can set the authRequired property.

If you set it to true, only authenticated users can access the page. Unauthenticated users are redirected to a route defined by the app.auth.onAuthFailedRedirectTo field.

page MainPage {
  component: import Main from "@client/pages/Main.jsx",
  authRequired: true
}
page MainPage {
  component: import Main from "@client/pages/Main.tsx",
  authRequired: true
}

:::caution Requires auth method You can only use authRequired if your app uses one of the available auth methods. :::

If authRequired is set to true, the page's React component (specified by the component property) receives the user object as a prop. Read more about the user object in the Accessing the logged-in user section.

Logout action

We provide an action for logging out the user. Here's how you can use it:

import logout from '@wasp/auth/logout'

const LogoutButton = () => {
  return <button onClick={logout}>Logout</button>
}
import logout from '@wasp/auth/logout'

const LogoutButton = () => {
  return <button onClick={logout}>Logout</button>
}

Accessing the logged-in user

You can get access to the user object both in the backend and on the frontend.

On the client

There are two ways to access the user object on the client:

  • the user prop
  • the useAuth hook

Using the user prop

If the page's declaration sets authRequired to true, the page's React component receives the user object as a prop:

// ...

page AccountPage {
  component: import Account from "@client/pages/Account.jsx",
  authRequired: true
}
import Button from './Button'
import logout from '@wasp/auth/logout'

const AccountPage = ({ user }) => {
  return (
    <div>
      <Button onClick={logout}>Logout</Button>
      {JSON.stringify(user, null, 2)}
    </div>
  )
}

export default AccountPage
// ...

page AccountPage {
  component: import Account from "@client/pages/Account.tsx",
  authRequired: true
}
import type { User } from '@wasp/entities'
import Button from './Button'
import logout from '@wasp/auth/logout'

const AccountPage = ({ user }: { user: User }) => {
  return (
    <div>
      <Button onClick={logout}>Logout</Button>
      {JSON.stringify(user, null, 2)}
    </div>
  )
}

export default AccountPage

Using the useAuth hook

Wasp provides a React hook you can use in the client components - useAuth.

This hook is a thin wrapper over Wasp's useQuery hook and returns data in the same format.

import useAuth from '@wasp/auth/useAuth'
import { Link } from 'react-router-dom'
import logout from '@wasp/auth/logout'
import Todo from '../Todo'

export function Main() {
  const { data: user } = useAuth()

  if (!user) {
    return (
      <span>
        Please <Link to="/login">login</Link> or{' '}
        <Link to="/signup">sign up</Link>.
      </span>
    )
  } else {
    return (
      <>
        <button onClick={logout}>Logout</button>
        <Todo />
      </>
    )
  }
}
import useAuth from '@wasp/auth/useAuth'
import { Link } from 'react-router-dom'
import logout from '@wasp/auth/logout'
import Todo from '../Todo'

export function Main() {
  const { data: user } = useAuth()

  if (!user) {
    return (
      <span>
        Please <Link to='/login'>login</Link> or <Link to='/signup'>sign up</Link>.
      </span>
    )
  } else {
    return (
      <>
        <button onClick={logout}>Logout</button>
        <Todo />
      < />
    )
  }
}

:::tip Since the user prop is only available in a page's React component: use the user prop in the page's React component and the useAuth hook in any other React component. :::

On the server

Using the context.user object

When authentication is enabled, all queries and actions have access to the user object through the context argument. context.user contains all User entity's fields, except for the password.

import HttpError from '@wasp/core/HttpError.js'

export const createTask = async (task, context) => {
  if (!context.user) {
    throw new HttpError(403)
  }

  const Task = context.entities.Task
  return Task.create({
    data: {
      description: task.description,
      user: {
        connect: { id: context.user.id },
      },
    },
  })
}
import type { Task } from '@wasp/entities'
import type { CreateTask } from '@wasp/actions/types'
import HttpError from '@wasp/core/HttpError.js'

type CreateTaskPayload = Pick<Task, 'description'>

export const createTask: CreateTask<CreateTaskPayload, Task> = async (
  args,
  context
) => {
  if (!context.user) {
    throw new HttpError(403)
  }

  const Task = context.entities.Task
  return Task.create({
    data: {
      description: args.description,
      user: {
        connect: { id: context.user.id },
      },
    },
  })
}

To implement access control in your app, each operation must check context.user and decide what to do. For example, if context.user is undefined inside a private operation, the user's access should be denied.

When using WebSockets, the user object is also available on the socket.data object. Read more in the WebSockets section.

User entity

Password hashing

You don't need to worry about hashing the password yourself. Even when directly using the Prisma client and calling create() with a plain-text password, Wasp's middleware makes sure to hash the password before storing it in the database. For example, if you need to update a user's password, you can safely use the Prisma client to do so, e.g., inside an Action:

export const updatePassword = async (args, context) => {
  return context.entities.User.update({
    where: { id: args.userId },
    data: {
      password: 'New pwd which will be hashed automatically!',
    },
  })
}
import type { UpdatePassword } from '@wasp/actions/types'
import type { User } from '@wasp/entities'

type UpdatePasswordPayload = {
  userId: User['id']
}

export const updatePassword: UpdatePassword<
  UpdatePasswordPayload,
  User
> = async (args, context) => {
  return context.entities.User.update({
    where: { id: args.userId },
    data: {
      password: 'New pwd which will be hashed automatically!',
    },
  })
}

Default validations

Wasp includes several basic validation mechanisms. If you need something extra, the next section shows how to customize them.

Default validations depend on the auth method you use.

Username & password

If you use Username & password authentication, the default validations are:

  • The username must not be empty
  • The password must not be empty, have at least 8 characters, and contain a number

Note that usernames are stored in a case-sensitive manner.

Email

If you use Email authentication, the default validations are:

  • The email must not be empty and a valid email address
  • The password must not be empty, have at least 8 characters, and contain a number

Note that emails are stored in a case-insensitive manner.

Customizing validations

:::note You can only disable the default validation for Username & password authentication, but you can add custom validations can to both Username & password and Email auth methods.

This is a bug in Wasp that is being tracked here :::

To disable/enable default validations, or add your own, modify your custom signup function:

const newUser = context.entities.User.create({
  data: {
    username: args.username,
    password: args.password, // password hashed automatically by Wasp! 🐝
  },
  _waspSkipDefaultValidations: false, // can be omitted if false (default), or explicitly set to true
  _waspCustomValidations: [
    {
      validates: 'password',
      message: 'password must contain an uppercase letter',
      validator: (password) => /[A-Z]/.test(password),
    },
  ],
})
const newUser = context.entities.User.create({
  data: {
    username: args.username,
    password: args.password, // password hashed automatically by Wasp! 🐝
  },
  _waspSkipDefaultValidations: false, // can be omitted if false (default), or explicitly set to true
  _waspCustomValidations: [
    {
      validates: 'password',
      message: 'password must contain an uppercase letter',
      validator: (password) => /[A-Z]/.test(password),
    },
  ],
})

:::info Validations always run on create(). For update(), they only run when the field mentioned in validates is present.

The validation process stops on the first validator to return false. If enabled, default validations run first and then custom validations. :::

Validation Error Handling

When creating, updating, or deleting entities, you may wish to handle validation errors. Wasp exposes a class called AuthError for this purpose.

try {
  await context.entities.User.update(...)
} catch (e) {
  if (e instanceof AuthError) {
    throw new HttpError(422, 'Validation failed', { message: e.message })
  } else {
    throw e
  }
}
try {
  await context.entities.User.update(...)
} catch (e) {
  if (e instanceof AuthError) {
    throw new HttpError(422, 'Validation failed', { message: e.message })
  } else {
    throw e
  }
}

Customizing the Signup Process

Sometimes you want to include extra fields in your signup process, like first name and last name.

In Wasp, in this case:

  • you need to define the fields that you want saved in the database,
  • you need to customize the SignupForm.

Other times, you might need to just add some extra UI elements to the form, like a checkbox for terms of service. In this case, customizing only the UI components is enough.

Let's see how to do both.

1. Defining Extra Fields

If we want to save some extra fields in our signup process, we need to tell our app they exist.

We do that by defining an object where the keys represent the field name, and the values are functions that receive the data sent from the client* and return the value of the field.

* We exclude the password field from this object to prevent it from being saved as plain-text in the database. The password field is handled by Wasp's auth backend.

First, we add the auth.signup.additionalFields field in our main.wasp file:

app crudTesting {
  // ...
  auth: {
    userEntity: User,
    methods: {
      usernameAndPassword: {},
    },
    onAuthFailedRedirectTo: "/login",
    signup: {
      additionalFields: import { fields } from "@server/auth/signup.js",
    },
  },
}

entity User {=psl
  id Int @id @default(autoincrement())
  username String @unique
  password String
  address String?
psl=}

Then we'll define and export the fields object from the server/auth/signup.js file:

import { defineAdditionalSignupFields } from '@wasp/auth/index.js'

export const fields = defineAdditionalSignupFields({
  address: async (data) => {
    const address = data.address
    if (typeof address !== 'string') {
      throw new Error('Address is required')
    }
    if (address.length < 5) {
      throw new Error('Address must be at least 5 characters long')
    }
    return address
  },
})
app crudTesting {
  // ...
  auth: {
    userEntity: User,
    methods: {
      usernameAndPassword: {},
    },
    onAuthFailedRedirectTo: "/login",
    signup: {
      additionalFields: import { fields } from "@server/auth/signup.js",
    },
  },
}

entity User {=psl
  id Int @id @default(autoincrement())
  username String @unique
  password String
  address String?
psl=}

Then we'll export the fields object from the server/auth/signup.ts file:

import { defineAdditionalSignupFields } from '@wasp/auth/index.js'

export const fields = defineAdditionalSignupFields({
  address: async (data) => {
    const address = data.address
    if (typeof address !== 'string') {
      throw new Error('Address is required')
    }
    if (address.length < 5) {
      throw new Error('Address must be at least 5 characters long')
    }
    return address
  },
})

Read more about the fields object in the API Reference.

Keep in mind, that these field names need to exist on the userEntity you defined in your main.wasp file e.g. address needs to be a field on the User entity.

The field function will receive the data sent from the client and it needs to return the value that will be saved into the database. If the field is invalid, the function should throw an error.

:::info Using Validation Libraries

You can use any validation library you want to validate the fields. For example, you can use zod like this:

Click to see the code
import { defineAdditionalSignupFields } from '@wasp/auth/index.js'
import * as z from 'zod'

export const fields = defineAdditionalSignupFields({
  address: (data) => {
    const AddressSchema = z
      .string({
        required_error: 'Address is required',
        invalid_type_error: 'Address must be a string',
      })
      .min(10, 'Address must be at least 10 characters long')
    const result = AddressSchema.safeParse(data.address)
    if (result.success === false) {
      throw new Error(result.error.issues[0].message)
    }
    return result.data
  },
})
import { defineAdditionalSignupFields } from '@wasp/auth/index.js'
import * as z from 'zod'

export const fields = defineAdditionalSignupFields({
  address: (data) => {
    const AddressSchema = z
      .string({
        required_error: 'Address is required',
        invalid_type_error: 'Address must be a string',
      })
      .min(10, 'Address must be at least 10 characters long')
    const result = AddressSchema.safeParse(data.address)
    if (result.success === false) {
      throw new Error(result.error.issues[0].message)
    }
    return result.data
  },
})

:::

Now that we defined the fields, Wasp knows how to:

  1. Validate the data sent from the client
  2. Save the data to the database

Next, let's see how to customize Auth UI to include those fields.

2. Customizing the Signup Component

:::tip Using Custom Signup Component

If you are not using Wasp's Auth UI, you can skip this section. Just make sure to include the extra fields in your custom signup form.

Read more about using the signup actions for:

  • email auth here
  • username & password auth here :::

If you are using Wasp's Auth UI, you can customize the SignupForm component by passing the additionalFields prop to it. It can be either a list of extra fields or a render function.

Using a List of Extra Fields

When you pass in a list of extra fields to the SignupForm, they are added to the form one by one, in the order you pass them in.

Inside the list, there can be either objects or render functions (you can combine them):

  1. Objects are a simple way to describe new fields you need, but a bit less flexible than render functions.
  2. Render functions can be used to render any UI you want, but they require a bit more code. The render functions receive the react-hook-form object and the form state object as arguments.
import { SignupForm } from '@wasp/auth/forms/Signup'
import {
  FormError,
  FormInput,
  FormItemGroup,
  FormLabel,
} from '@wasp/auth/forms/internal/Form'

export const SignupPage = () => {
  return (
    <SignupForm
      additionalFields={[
        /* The address field is defined using an object */
        {
          name: 'address',
          label: 'Address',
          type: 'input',
          validations: {
            required: 'Address is required',
          },
        },
        /* The phone number is defined using a render function */
        (form, state) => {
          return (
            <FormItemGroup>
              <FormLabel>Phone Number</FormLabel>
              <FormInput
                {...form.register('phoneNumber', {
                  required: 'Phone number is required',
                })}
                disabled={state.isLoading}
              />
              {form.formState.errors.phoneNumber && (
                <FormError>
                  {form.formState.errors.phoneNumber.message}
                </FormError>
              )}
            </FormItemGroup>
          )
        },
      ]}
    />
  )
}
import { SignupForm } from '@wasp/auth/forms/Signup'
import {
  FormError,
  FormInput,
  FormItemGroup,
  FormLabel,
} from '@wasp/auth/forms/internal/Form'

export const SignupPage = () => {
  return (
    <SignupForm
      additionalFields={[
        /* The address field is defined using an object */
        {
          name: 'address',
          label: 'Address',
          type: 'input',
          validations: {
            required: 'Address is required',
          },
        },
        /* The phone number is defined using a render function */
        (form, state) => {
          return (
            <FormItemGroup>
              <FormLabel>Phone Number</FormLabel>
              <FormInput
                {...form.register('phoneNumber', {
                  required: 'Phone number is required',
                })}
                disabled={state.isLoading}
              />
              {form.formState.errors.phoneNumber && (
                <FormError>
                  {form.formState.errors.phoneNumber.message}
                </FormError>
              )}
            </FormItemGroup>
          )
        },
      ]}
    />
  )
}

Read more about the extra fields in the API Reference.

Using a Single Render Function

Instead of passing in a list of extra fields, you can pass in a render function which will receive the react-hook-form object and the form state object as arguments. What ever the render function returns, will be rendered below the default fields.

import { SignupForm } from '@wasp/auth/forms/Signup'
import { FormItemGroup } from '@wasp/auth/forms/internal/Form'

export const SignupPage = () => {
  return (
    <SignupForm
      additionalFields={(form, state) => {
        const username = form.watch('username')
        return (
          username && (
            <FormItemGroup>
              Hello there <strong>{username}</strong> 👋
            </FormItemGroup>
          )
        )
      }}
    />
  )
}
import { SignupForm } from '@wasp/auth/forms/Signup'
import { FormItemGroup } from '@wasp/auth/forms/internal/Form'

export const SignupPage = () => {
  return (
    <SignupForm
      additionalFields={(form, state) => {
        const username = form.watch('username')
        return (
          username && (
            <FormItemGroup>
              Hello there <strong>{username}</strong> 👋
            </FormItemGroup>
          )
        )
      }}
    />
  )
}

Read more about the render function in the API Reference.

API Reference

Auth Fields

  title: "My app",
  //...
  auth: {
    userEntity: User,
    externalAuthEntity: SocialLogin,
    methods: {
      usernameAndPassword: {}, // use this or email, not both
      email: {}, // use this or usernameAndPassword, not both
      google: {},
      gitHub: {},
    },
    onAuthFailedRedirectTo: "/someRoute",
    signup: { ... }
  }
}

//...
app MyApp {
  title: "My app",
  //...
  auth: {
    userEntity: User,
    externalAuthEntity: SocialLogin,
    methods: {
      usernameAndPassword: {}, // use this or email, not both
      email: {}, // use this or usernameAndPassword, not both
      google: {},
      gitHub: {},
    },
    onAuthFailedRedirectTo: "/someRoute",
    signup: { ... }
  }
}

//...

app.auth is a dictionary with the following fields:

userEntity: entity

The entity representing the user. Its mandatory fields depend on your chosen auth method.

externalAuthEntity: entity

Wasp requires you to set the field auth.externalAuthEntity for all authentication methods relying on an external authorizatino provider (e.g., Google). You also need to tweak the Entity referenced by auth.userEntity, as shown below.

//...
  auth: {
    userEntity: User,
    externalAuthEntity: SocialLogin,
//...

entity User {=psl
    id                        Int           @id @default(autoincrement())
    //...
    externalAuthAssociations  SocialLogin[]
psl=}

entity SocialLogin {=psl
  id          Int       @id @default(autoincrement())
  provider    String
  providerId  String
  user        User      @relation(fields: [userId], references: [id], onDelete: Cascade)
  userId      Int
  createdAt   DateTime  @default(now())
  @@unique([provider, providerId, userId])
psl=}
//...
  auth: {
    userEntity: User,
    externalAuthEntity: SocialLogin,
//...

entity User {=psl
    id                        Int           @id @default(autoincrement())
    //...
    externalAuthAssociations  SocialLogin[]
psl=}

entity SocialLogin {=psl
  id          Int       @id @default(autoincrement())
  provider    String
  providerId  String
  user        User      @relation(fields: [userId], references: [id], onDelete: Cascade)
  userId      Int
  createdAt   DateTime  @default(now())
  @@unique([provider, providerId, userId])
psl=}

:::note The same externalAuthEntity can be used across different social login providers (e.g., both GitHub and Google can use the same entity). :::

See Google docs and GitHub docs for more details.

methods: dict

A dictionary of auth methods enabled for the app.

onAuthFailedRedirectTo: String

The route to which Wasp should redirect unauthenticated user when they try to access a private page (i.e., a page that has authRequired: true). Check out these essentials docs on auth to see an example of usage.

onAuthSucceededRedirectTo: String

The route to which Wasp will send a successfully authenticated after a successful login/signup. The default value is "/".

:::note Automatic redirect on successful login only works when using the Wasp-provided Auth UI. :::

signup: SignupOptions

Read more about the signup process customization API in the Signup Fields Customization section.

Signup Fields Customization

If you want to add extra fields to the signup process, the server needs to know how to save them to the database. You do that by defining the auth.signup.additionalFields field in your main.wasp file.

app crudTesting {
  // ...
  auth: {
    userEntity: User,
    methods: {
      usernameAndPassword: {},
    },
    onAuthFailedRedirectTo: "/login",
    signup: {
      additionalFields: import { fields } from "@server/auth/signup.js",
    },
  },
}

Then we'll export the fields object from the server/auth/signup.js file:

import { defineAdditionalSignupFields } from '@wasp/auth/index.js'

export const fields = defineAdditionalSignupFields({
  address: async (data) => {
    const address = data.address
    if (typeof address !== 'string') {
      throw new Error('Address is required')
    }
    if (address.length < 5) {
      throw new Error('Address must be at least 5 characters long')
    }
    return address
  },
})
app crudTesting {
  // ...
  auth: {
    userEntity: User,
    methods: {
      usernameAndPassword: {},
    },
    onAuthFailedRedirectTo: "/login",
    signup: {
      additionalFields: import { fields } from "@server/auth/signup.js",
    },
  },
}

Then we'll export the fields object from the server/auth/signup.ts file:

import { defineAdditionalSignupFields } from '@wasp/auth/index.js'

export const fields = defineAdditionalSignupFields({
  address: async (data) => {
    const address = data.address
    if (typeof address !== 'string') {
      throw new Error('Address is required')
    }
    if (address.length < 5) {
      throw new Error('Address must be at least 5 characters long')
    }
    return address
  },
})

The fields object is an object where the keys represent the field name, and the values are functions which receive the data sent from the client* and return the value of the field.

If the field value is invalid, the function should throw an error.

* We exclude the password field from this object to prevent it from being saved as plain-text in the database. The password field is handled by Wasp's auth backend.

SignupForm Customization

To customize the SignupForm component, you need to pass in the additionalFields prop. It can be either a list of extra fields or a render function.

import { SignupForm } from '@wasp/auth/forms/Signup'
import {
  FormError,
  FormInput,
  FormItemGroup,
  FormLabel,
} from '@wasp/auth/forms/internal/Form'

export const SignupPage = () => {
  return (
    <SignupForm
      additionalFields={[
        {
          name: 'address',
          label: 'Address',
          type: 'input',
          validations: {
            required: 'Address is required',
          },
        },
        (form, state) => {
          return (
            <FormItemGroup>
              <FormLabel>Phone Number</FormLabel>
              <FormInput
                {...form.register('phoneNumber', {
                  required: 'Phone number is required',
                })}
                disabled={state.isLoading}
              />
              {form.formState.errors.phoneNumber && (
                <FormError>
                  {form.formState.errors.phoneNumber.message}
                </FormError>
              )}
            </FormItemGroup>
          )
        },
      ]}
    />
  )
}
import { SignupForm } from '@wasp/auth/forms/Signup'
import {
  FormError,
  FormInput,
  FormItemGroup,
  FormLabel,
} from '@wasp/auth/forms/internal/Form'

export const SignupPage = () => {
  return (
    <SignupForm
      additionalFields={[
        {
          name: 'address',
          label: 'Address',
          type: 'input',
          validations: {
            required: 'Address is required',
          },
        },
        (form, state) => {
          return (
            <FormItemGroup>
              <FormLabel>Phone Number</FormLabel>
              <FormInput
                {...form.register('phoneNumber', {
                  required: 'Phone number is required',
                })}
                disabled={state.isLoading}
              />
              {form.formState.errors.phoneNumber && (
                <FormError>
                  {form.formState.errors.phoneNumber.message}
                </FormError>
              )}
            </FormItemGroup>
          )
        },
      ]}
    />
  )
}

The extra fields can be either objects or render functions (you can combine them):

  1. Objects are a simple way to describe new fields you need, but a bit less flexible than render functions.

    The objects have the following properties:

    • name

      • the name of the field
    • label

      • the label of the field (used in the UI)
    • type

      • the type of the field, which can be input or textarea
    • validations

      • an object with the validation rules for the field. The keys are the validation names, and the values are the validation error messages. Read more about the available validation rules in the react-hook-form docs.
  2. Render functions receive the react-hook-form object and the form state as arguments, and they can use them to render arbitrary UI elements.

    The render function has the following signature:

    ;(form: UseFormReturn, state: FormState) => React.ReactNode
    
    • form

      • the react-hook-form object, read more about it in the react-hook-form docs
      • you need to use the form.register function to register your fields
    • state

      • the form state object which has the following properties:
        • isLoading: boolean
          • whether the form is currently submitting