nextauthjs / next-auth

Authentication for the Web.
https://authjs.dev
ISC License
24.96k stars 3.52k forks source link

Provide a way to save updated `provider.profile()` information to the users database #7654

Closed domharrington closed 1 year ago

domharrington commented 1 year ago

Description šŸ““

This feature request/issue seems to come up a lot. I would like there to be a (standardised) way to update the user that's stored in the database on, or after initial creation. My use case is that I would like to store a username property on the users collection, but we already have some existing users who have logged into the system. Overriding the provider.profile() function is possible (and would work for new users), but for existing users the updated information doesn't save to the database.

How to reproduce ā˜•ļø

Due to the extensibility of this module, there are a ton of different ways to implement this currently:

Override profile() in your oauth provider:

https://next-auth.js.org/configuration/providers/oauth#override-default-options

  • this works, but only for new users
  • you have to copy and paste the original oauth code if using a standard provider, to make sure you dont miss something

Add another db update call to one of the callbacks (signIn() or jwt())

https://next-auth.js.org/configuration/callbacks

  • this works, but means the callback is blocked on database operations
  • for JWT (which happens a lot), this could cause a performance penalty
  • for signIn, the user.id property doesn't appear to match up with what's saved in the database - can i call db.update() here? can i just append stuff onto the user object - is that gunna work/continue to work?
  • if you're using multiple providers, you have to write code to handle both of them in the same function (which isn't that bad)

Override the adapter methods (createUser/updateUser)


Is there a recommended approach here? Have I missed something in the docs? It would be great to have a unified and consistent way to do this across providers. Maybe via an extendProfile() callback at the provider level:

import GithubProvider from "next-auth/providers/github";

export const authOptions = {
  providers: [
    GithubProvider({
      clientId: process.env.NEXT_PUBLIC_GITHUB_CLIENT_ID,
      clientSecret: process.env.GITHUB_SECRET,
      extendProfile({ user, profile }) {
        return { ...user, username: profile.login }
      }
    }),
  ]
}

That way you dont have to mess around with overriding profiles per provider and whatever is returned from this function should be saved to the database each time.

Related issues: https://github.com/nextauthjs/next-auth/issues/7100 https://github.com/nextauthjs/next-auth/issues/6959 https://github.com/nextauthjs/next-auth/issues/6952#issuecomment-1471751706

Similar feature requests: https://github.com/nextauthjs/next-auth/discussions/7548 https://github.com/nextauthjs/next-auth/discussions/1194 https://github.com/nextauthjs/next-auth/discussions/4865

Contributing šŸ™ŒšŸ½

Yes, I am willing to help implement this feature in a PR

domharrington commented 1 year ago

I just tried to do this via the signIn callback, and similarly to the profile() function it only works to modify the user object pre-save the very first time it's called. On subsequent calls, this has no effect:

export const authOptions = {
  callbacks: {
    signIn({ user, profile, account }) {
      user.provider = account.provider;
      switch (account.provider) {
        case 'github':
          user.username = profile.login;
          break;
        case 'bitbucket':
          user.username = profile.username;
          break;
      }
      return true;
    },
  }
}
domharrington commented 1 year ago

In the end I went with this which will append the user with account.provider and username properties extracted from the profile. This works for both new users (the prisma call fails and we handle it) and existing users (via the prisma update() call).

import { Prisma, PrismaClient } from '@prisma/client'

const client = new PrismaClient()

export const authOptions = {
  callbacks: {
    async signIn({ user, profile, account }) {
      user.provider = account.provider;
      switch (account.provider) {
        case "github":
          user.username = profile.login;
          break;
        case "bitbucket":
          user.username = profile.username;
          break;
      }
      try {
        await client.users.update({
          where: { email: user.email },
          data: { provider: user.provider, username: user.username },
        })
      } catch (e) {
        // Rethrow any error that isn't known. This error will get thrown
        // if we're trying to update a user in the database that doesn't exist
        // yet i.e. they're a new user.
        // https://www.prisma.io/docs/reference/api-reference/error-reference#p2025
        if (e instanceof Prisma.PrismaClientKnownRequestError) {
          if (e.code !== 'P2025') {
            throw e;
          }
        }
      }
      return true;
    },
  }
}

For a simplified example with a single provider (github), you could do this:

import { Prisma, PrismaClient } from '@prisma/client'

const client = new PrismaClient()

export const authOptions = {
  callbacks: {
    async signIn({ user, profile, account }) {
      user.provider = account.provider;
      user.username = profile.login;
      try {
        await client.users.update({
          where: { email: user.email },
          data: { provider: user.provider, username: user.username },
        })
      } catch (e) {
        // Rethrow any error that isn't known. This error will get thrown
        // if we're trying to update a user in the database that doesn't exist
        // yet i.e. they're a new user.
        // https://www.prisma.io/docs/reference/api-reference/error-reference#p2025
        if (e instanceof Prisma.PrismaClientKnownRequestError) {
          if (e.code !== 'P2025') {
            throw e;
          }
        }
      }
      return true;
    },
  }
}

This could also be extended to add other custom default properties, not from account/profile. I hope this helps someone! Or if there's anything wrong with this approach, lmk!

I hope we can come up with an approach that isn't so verbose together - happy to submit a fix if you can offer some advice on what API design will be accepted.

balazsorban44 commented 1 year ago

There is a standard way, please refer to the documentation: https://next-auth.js.org/getting-started/client#updating-the-session

domharrington commented 1 year ago

Hey, thanks for responding! This is client side only though, no? Is there any way to do this from the server side? And please correct me if i'm wrong, but this only updates the current active session not the user that's stored in the database?

I'm hoping to save the username that comes back from the oauth login - what would be the recommended flow using update()? I'd have to append the username onto the jwt token during sign in (callbacks.jwt()) then i'd have to append this onto the session (callbacks.session()) then when i'm on the frontend, I'd call update() on the session based on this value? I may be misunderstanding here, but I probably wouldn't need to do this because that value would already be in the session at that point and I'm looking to update the user stored in the database. I hope that makes sense.

Cheers

ericvoshall commented 1 year ago

Interested in a server side solution as well!

domharrington commented 1 year ago

The solution i posted here has been working for me! If it's a new user, you can just update the user object passed in, if it's an existing user you can update the user with your adapter. I actually simplified my code above to use the adapter instead of prisma directly:

async signIn({ user, profile, account }) {
  user.provider = account.provider;
  user.username = profile.login;
  if (user.id) {
    await adapter.updateUser({
      id: user.id,
      provider: user.provider,
      username: user.username,
    });
  }
  return true;
},

Hope this helps!

sheeni17 commented 9 months ago

The solution i posted here has been working for me! If it's a new user, you can just update the user object passed in, if it's an existing user you can update the user with your adapter. I actually simplified my code above to use the adapter instead of prisma directly:

async signIn({ user, profile, account }) {
  user.provider = account.provider;
  user.username = profile.login;
  if (user.id) {
    await adapter.updateUser({
      id: user.id,
      provider: user.provider,
      username: user.username,
    });
  }
  return true;
},

Hope this helps!

Hey @domharrington thanks for the solution. Just a quick one, have you extended the AdapterUser type anywhere? are you using the adapter that you are passing to the config? im getting type errors (for additional fields that i have added for the user)

domharrington commented 9 months ago

@sheeni17 I wasn't using TypeScript in this project, so I'm not sure on the proper way to type this, but yeah you'd probably have to extend it if it's not typed on there.

gnowland commented 4 months ago

Hey @domharrington thanks for the solution. Just a quick one, have you extended the AdapterUser type anywhere? are you using the adapter that you are passing to the config? im getting type errors (for additional fields that i have added for the user)

You can extend AdapterUser like so:

import { AdapterUser } from '@auth/core/adapters';

declare module '@auth/core/adapters' {
  interface AdapterUser {
    customField?: string;
  }
}
gnowland commented 4 months ago

I actually simplified my code above to use the adapter instead of prisma directly:


    await adapter.updateUser(...)

~@domharrington I'm missing something... where is adapter being defined?~ Never mind, I just had to use config.adapter šŸ¤¦ā€ā™‚ļø

Netyson commented 2 months ago

Hey everyone, I'm running into the same issue where adapter is not defined in my signIn callback function. I've tried to follow the guidance from above, but I still can't seem to get it working correctly.

Here's my setup:

async signIn({ user, account, profile, email, credentials }) {
  await adapter.updateUser({
    id: user.id,
  });
  return true;
}

And my auth.ts file looks like this:

import NextAuth from "next-auth";
import Google from "next-auth/providers/google";
import { SupabaseAdapter } from "@auth/supabase-adapter";

export const { handlers, signIn, signOut, auth } = NextAuth({
  debug: true,
  adapter: SupabaseAdapter({
    url: '',
    secret: '',
  }),
  session: {
    strategy: "database",
  },
  providers: [
    //... other providers
  ],
});

However, I keep getting the error: Cannot find name 'adapter'.

I saw that someone mentioned using config.adapter, but I'm not sure where exactly to define or use it within this context. Any help or further guidance would be greatly appreciated!

Thank you in advance!

gnowland commented 2 months ago

Hey @Netyson, a couple suggestions-

I recommend putting anything that isn't directly related to "should we allow this person to sign in" in the events: parameter instead of callbacks:. I originally had my db updates in callbacks.signIn but found out the hard way that if there was an issue communicating with the database it resulted in the entire auth module telling everyone they were not authorized to sign in (because the return was falsy).

Secondly, if you put the auth config into its own variable it will help, because you need to reference the value of your config adapter (authConfig.adapter) in your signIn function (as I noted in my comment just above yours šŸ˜Ž) - that's why your code is complaining it's not defined.

Try this (note I like to use authConfig instead of config as my variable name to help keep things organized, but you can call it whatever you like):

import NextAuth from "next-auth";
import Google from "next-auth/providers/google";
import { SupabaseAdapter } from "@auth/supabase-adapter";

export const authConfig = {
  debug: true,
  adapter: SupabaseAdapter({
    url: '',
    secret: '',
  }),
  session: {
    strategy: "database",
  },
  providers: [
    //... other providers
  ],
  events: {
    signIn: async (message) => {
      const { account, profile, user, isNewUser } = message;
      const { updateUser } = authConfig.adapter || {};

      if (updateUser != null) {
        await updateUser({ id: user.id });
      }
    }
  },
});

export const { handlers, signIn, signOut, auth } = NextAuth(authConfig);

Here are the docs for events: https://authjs.dev/reference/core#events

Netyson commented 2 months ago

Hey @gnowland,

Thanks to your comments and suggestions, I managed to fix my issue and update the user information on every login. Your help was really aweseome!

Cheers!