Redwood and Supabase Auth Example

Thanks @janpio for this update an example:

const [ignore, userList, updateUser] = await prisma.$transaction([
  prisma.$executeRaw(`SET current_user_id = ${currentUser.id}`),
  prisma.post.findMany({ where: { title: { contains: 'prisma' } } }),
  prisma.post.count(),
])

@kiwicopple I looked at the RLS and policy docs here: Auth | Supabase and

Supabase provides a special function in Postgres, auth.uid(), which extracts the user’s UID from the JWT. This is especially useful when creating Policies.

But since it looks like that relies on the request:

CREATE OR REPLACE FUNCTION auth.uid()
 RETURNS uuid
 LANGUAGE sql
 STABLE
AS $function$
  select nullif(current_setting('request.jwt.claim.sub', true), '')::uuid;
$function$

perhaps

create policy "Users can update their own profiles." 
  on profiles for update using (
    auth.uid() = id
  );

would be

create policy "Users can update their own profiles." 
  on profiles for update using (
   current_user_id = id
  );

given the above Prisma example when setting current_user_id ahead of the query?

I guess I’ll have to figure out a good way of testing this – will try this week.

1 Like

Following this thread with much interest. As already stated, RBAC is very important building apps today. I am working in the innovation sector and we are building new apps on a monthly basis. Having a toolset like Supabase in combo with RedwoodJS is something that gives us the necessary boost that I am really looking forward to.

Any updates on this one?

Hi @saendu and welcome!

I have not had a chance unfortunately, but I am working on an app where I could use RLS (row level security).

Note that RedwoodJS does have RBAC support Cookbook - Role-based Access Control (RBAC) : RedwoodJS Docs and .

What we’re wanting to try is to see if that role security can also extend into the Postgres level in policies rather than just in the service business logic api side.

For Supabase, you’d want to set your “app roles” in app_metadata as a collection of roles:

   "app_metadata": {
        "provider": "email"
      },
      "user_metadata": null,
      "role": "authenticated"
    },

as in:

{
  "exp": 1598628532,
  "sub": "1d271db5-f0cg-21f4-8b43-a01ddd3be294",
  "email": "example+author@example.com",
  "app_metadata": {
    "roles": ["author"]
  },
  "user_metadata": {
    "full_name": "Arthur Author",
  }
}

If you happen to try this approach out, please let us know how it worked out.

Hi @dthyresson

Sorry for the confusion with my statement about the “importance” of RBAC. I fully understand that redwood already has RBAC in place. I already tried using it with Supabase with success.
For that matter I was following the cookbook and enriching the user object with roles and for my case also with the uuid from the users table in supabase like so:

export const getCurrentUser = async (decoded) => {

  const uuid = decoded.sub

  const userRoles = await db.userRole.findMany({
    where: { user: { uuid: uuid } },
    select: { name: true },
  })

  const roles = userRoles.map((role) => {
    return role.name
  })

  const { id: userId } = await db.user.findUnique({
    where: { uuid: uuid },
    select: { id: true  },
  })

  const enrichedUserObj = { ...decoded, roles, userId };
  return enrichedUserObj
}

With this approach security is enforced on the service side rather than the Postgres side directly. Or in other words, hitting the the Supabase API directly would still return “unfiltered” data since no RLS is enforced. Enabling RLS and writing security rules like this create policy "Individuals can view their own todos." on todos for select using (auth.uid() = user_id); would not really do any good, since RW is always operating with a service token.

If I understood you correctly, you are trying to address this problem by finding a way combining RLS with RBAC, correct?

Yes, that’s correct - most/all implementations have sort of been done as you have, though with Supabase I do setup the trigger from auth.user to publuc.User and then keep roles also in the auth schema and set on the auth.user in raw_app_metadata. This saves all the db lookups get getUserRequest.

Also, when using Supabase be sure to use Connection Pooling.

Think this was just a misunderstanding in that when I thought you asked about “any updates” on RLS.

1 Like

@saendu Thanks for the example. I have implemented similar however I am having trouble extracting and using the accountId. What I would like to do is navigate users to an account page after logIn based on their accountId – see code snippets.

Should accountId be part of the currentUser object?

// api/src/lib/auth.js

export const getCurrentUser = async (decoded) => {
  const uuid = decoded.sub

  const { id: accountId } = await db.user.findUnique({
    where: { uuid: uuid },
    select: { accountId: true },
  })

  const enrichedUserObj = { ...decoded, accountId }
  return enrichedUserObj
}
// src/components/LoginForm

  const onSubmit = async (input) => {
    const email = input.email
    const password = input.password
    await logIn({ email, password }).then(() => {
      navigate(routes.account({ id: currentUser.accountId }))
    })

A more generic question… when logged-in as a Supabse user what is the expected result of the following?

query {
  redwood {
    currentUser
    }
  }

I get the following even though I can display the logged-in user email on screen

{
  "data": {
    "redwood": {
      "currentUser": null
    }
  }
}

This is what I see in the console

And this works as expected

{isAuthenticated && <>{currentUser.email}</>}

Hi @0x1a4f7d58 Yes, you should see the result of the getCurrentUser there.

You can have a look at the Auth Playground for Supabase to test signups and see the result.

https://redwood-playground-auth.netlify.app/supabase

Are you certain

  const { id: accountId } = await db.user.findUnique({
    where: { uuid: uuid },
    select: { accountId: true },
  })

  const enrichedUserObj = { ...decoded, accountId }

That accountId has a value here - perhaps

import { logger } from '@redwoodjs/api/logger'

and then logger.debug({accountId}, 'This is the user account id')?

in your getCurrentUser().

Also, another way to do this is to

const { userMetadata } = useAuth()

And look there.

That also has more info, specifically info from the raw_user_meta_data filed on the users table in Supabase’s auth schema.

image

Any data you set there (say from a trigger on the public schema side) would be available.

So you could set that and save a db call on every getCurrentUser request.

Also, if you are using Supabase in a serverless environment, please be sure to use connection pooling.

See: Docs - Connection Pooling : RedwoodJS Docs

1 Like

@dthyresson Thanks a lot. The logger turned out to be v helpful and I managed to resolve the issue. Good to know about {userMetadata} too :ok_hand:

1 Like

@saendu You can ignore this. I got it working with the following… and am no-longer trying to navigate based on currentUser. Using the context instead :slight_smile:

//api/src/lib/auth.js

export const getCurrentUser = async (
  decoded,
  { _token, _type },
  { _event, _context }
) => {
  const uuid = decoded.sub

  const { accountId } = await db.user.findUnique({
    where: { uuid: uuid },
    select: { accountId: true },
  })

  const { slug } = await db.account.findUnique({
    where: { id: accountId },
    select: { slug: true },
  })

  // logger.debug(
  //   {
  //     payload: { decoded, accountId, slug },
  //   },
  //   'Current User details'
  // )

  const enrichedUserObj = { ...decoded, accountId, slug }
  return enrichedUserObj
}

@0x1a4f7d58 Have you tried some Prisma nested reads?

In their example:

const getUser = await prisma.user.findUnique({
  where: {
    id: 1,
  },
  // select: { name: true } <-- Can't do this!
  include: {
    posts: {
      select: {
        title: true,
      },
    },
  },
})

and that returns


{
  "id": 1,
  "name": null,
  "email": "martina@prisma.io",
  "profileViews": 0,
  "role": "USER",
  "coinflips": [],
  "posts": [
    {
      "title": "How to grow salad"
    },
    {
      "title": "How to ride a horse"
    }
  ]
}

you might be able to do

  const user = await db.user.findUnique({
    where: { uuid: uuid },
    include: {
      account: {
        select: {
          slug: true,
        },
     }
  })

And the user would have all the user attributes and probably an account with a slug attribute.

I think.

const enrichedUserObj = { ...decoded, user.accountId, user.account.slug }

Or, you could just do a nested query on the account and include, join in on the user uuid to return the id of the account and its slug.

That way it is one single db call.

1 Like

@dthyresson Thanks. The query works, however, passing the values to the enriched object doesn’t. I’m OK to leave as is for the time being but get that it would be better to do it in a single query.

> db.user.findUnique({where: {uuid: 'cfe9bd27-0122-4fe8-ab58-3f7a6bfb6d43'}, include: {account: {select: {slug: true}}}})
{
  id: 126,
  uuid: 'cfe9bd27-0122-4fe8-ab58-3f7a6bfb6d43',
  createdAt: 2021-07-08T14:37:34.877Z,
  updatedAt: 2021-07-08T14:37:34.877Z,
  email: 'email@email.com',
  name: null,
  accountId: 130,
  account: { slug: 'V52NDE' }
}

Ah, I was coding on the fly, I bet it should be

const enrichedUserObj = { ...decoded, accountId: user.accountId, slug: user.account.slug }
1 Like

Thanks @dthyresson. That works :+1:

I have been experimenting with userMetadata and it’s not clear how I can write to either userMetadata.app_metadata or userMetadata.user_metadata.

Supabase API:

UPDATE USER
const { user, error } = await supabase.auth.update({
  email: "new@email.com",
  password: "new-password",
  data: { hello: 'world' }
})

Should I write a custom function for this? Something like Update user data · Issue #23 · supabase/supabase-js · GitHub

I noticed in Docs - Authentication : RedwoodJS Docs that there is an Auth Provider Specific Integration section for most providers but not yet Supabase. If you know of an example or can provide any guidance, that would great. I can update the docs once clear.


Btw, this post might be more suited to this thread Supabase redwood experiments

Hi @0x1a4f7d58 Actually I think that’s exactly how you would update the user metdata:

But, you can still to this in a service.

  • Create SDL for a Mutation that defines the attributes you want in userMetadata (you could do JSON, but better probably if you set). Call that mutation maybe updateUserMetadata()
  • In a userMetadata service, create you update method
  • That method should
  • requireAuth so you make sure they are logged in
  • create an instance of the supabase client but instead of using the non key or the superadmin service role key, use the currentUser’s token. That will say that the supabase client can only act on that user’s info
  • then supabase.auth.update() the input data from your mutation

You should be able to get the bearer token using context[1].token

So in that service, your supabase client is:

const supabase = createClient('https://xyzcompany.supabase.co', context[1].token)
// ...
const { user, error } = await supabase.auth.update({
  data: { hello: 'world' }
})
export type AuthContextPayload = [
  string | Record<string, unknown> | null,
  { type: SupportedAuthTypes } & AuthorizationHeader,
  { event: APIGatewayProxyEvent; context: GlobalContext & LambdaContext }
]

is what context has so and index 1 is { type: SupportedAuthTypes } & AuthorizationHeader, and the AuthorizationHeader has

export interface AuthorizationHeader {
  schema: 'Bearer' | 'Basic' | string
  token: string
}

So that’s where you get your token to use when creating the supabase client.

I haven’t tried this code, but maybe something like:

// userMetadata.sdl
export const schema = gql`
  type UserMetadata {
    full_name: String!
    favorite_color: String
  }
  type Query {
    userMetadata: UserMetadata!
  }
  input UpdateUserMetadataInput {
    full_name: String
    favorite_color: String
  }
  type Mutation {
    updateUserMetadata(input: UpdateUserMetadataInput!): UserMetadata!
  }
`

// services/userMetadata.js
import { createClient } from '@supabase/supabase-js'

export const updateUserMetadata = async ({ input }) => {
  requireAuth()
 
 const supabase = createClient('https://xyzcompany.supabase.co', context[1].token)

 const { user, error } = await supabase.auth.update({
  data: { ...input }
})

return user.user_metadata
}

Or something close to that.

1 Like

Hi @dthyresson Thanks! I think I’m v close.

  1. I had to add the following to graphql.js in order for the context to work, as described here
// api/src/functions/graphql.js
import { getAuthenticationContext } from '@redwoodjs/api/dist/auth'

export const handler = createGraphQLHandler({
  context: async ({ event, context }) => {
    const authContext = await getAuthenticationContext({ event, context })
    return authContext
  },
...
  1. I can make status 200 posts, however, I cannot get the data to save down. I end-up with {"data":{"updateUserMetadata":null}}

This is what I’ve got

// userMetadata.sdl.js
export const schema = gql`
  type UserMetadata {
    firstName: String
    currentLocationId: Int
  }
  type Query {
    userMetadata: UserMetadata
  }
  input UpdateUserMetadataInput {
    firstName: String
    currentLocationId: Int
  }
  type Mutation {
    updateUserMetadata(input: UpdateUserMetadataInput!): UserMetadata
  }
`

and

//userMetadata.js

import { requireAuth } from 'src/lib/auth'
import { createClient } from '@supabase/supabase-js'

export const updateUserMetadata = async ({ input }) => {
  requireAuth()

  const supabase = createClient(process.env.SUPABASE_URL, context[1].token)

  const { user, error } = await supabase.auth.update({
    data: {
      firstName: input.firstName,
      currentLocationId: input.currentLocationId,
    },
    // data: { ...input },
  })

  return user
}

export const userMetadata = () => {
  const supabase = createClient(
    process.env.SUPABASE_URL,
    process.env.SUPABASE_KEY
  )

  const user = supabase.auth.user()

  return user
}

Note: return user.user_metadata here gives “Cannot read property ‘user_metadata’ of null”

and then I’m calling the service and passing data on an onClick event (just for testing purposes)

//HomePage.js
...

const UPDATE_USER_METADATA = gql`
  mutation UpdateUserMetadata($input: UpdateUserMetadataInput!) {
    updateUserMetadata(input: $input) {
      firstName
      currentLocationId
    }
  }
`

const HomePage = () => {
  const [update] = useMutation(UPDATE_USER_METADATA, {
    onCompleted: async (data) => {
      toast.success('User metadata updated!')
    },
  })

  const onClick = async () => {
    update({
      variables: {
        input: { firstName: 'Metadata', currentLocationId: 130 },
      },
    })
  }
...

Appreciate that’s a lot to take in.

Not 100% on this but in your cell handling after the metadata update, instead of showing information from the graphql result, you would want to show the userMetadata from useAuth() hook.

What I wonder, though, is if you will need to force Supabase to re-authenticate and update the user metadata is has stored in its session.

You can get the client from the useAuth() hook as well and force a re-auth via await reauthenticate().

That might help.

1 Like

Also, if you log out and login again, do you see your userMetadata updates?

I don’t. Feels like the mutation is failing and input = null is being passed to the service (supabase.auth.update)

Here’s what I see after I click

image

image

I don’t know if you found a solution, I found this: Supabase authentication && reauthenticate

1 Like