[GH-ISSUE #2436] Generic OAuth not saving refreshToken to database when using client.oauth2.link #9192

Closed
opened 2026-04-13 04:34:26 -05:00 by GiteaMirror · 2 comments
Owner

Originally created by @mfragale on GitHub (Apr 25, 2025).
Original GitHub issue: https://github.com/better-auth/better-auth/issues/2436

Is this suited for github?

  • Yes, this is suited for github

To Reproduce

  1. Link an existing credential account with a Generic oAuth account using:
await client.oauth2.link({
    providerId: "planning-center",
    callbackURL: "/api/planning-center/pco-actions",
});
  1. Check DB and see that accessToken and accessTokenExpiresAt where correctly added to the DB but no refreshToken and refreshTokenExpiresAt where added to the DB.

Current vs. Expected behavior

I expected the DB to be populated with accessToken and accessTokenExpiresAt as well as with refreshToken and refreshTokenExpiresAt.

What version of Better Auth are you using?

1.2.7

Provide environment information

- OS: Mac OS 15.4.1
- Browser: Chrome

Which area(s) are affected? (Select all that apply)

Backend

Auth config (if applicable)

import { stripe } from "@better-auth/stripe";
import { BetterAuthOptions, betterAuth } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { nextCookies } from "better-auth/next-js";
import {
  admin as adminPlugin,
  bearer,
  genericOAuth,
  openAPI,
  organization,
  twoFactor,
} from "better-auth/plugins";
import Stripe from "stripe";

import { sendEmail } from "@/actions/send-emails/route";
import { db } from "@/drizzle/db";
import { env } from "@/env/server";
import { getActiveOrganization } from "@/features/users/db/users";
import { ac, admin, myCustomRole, user } from "@/lib/permissions";

const stripeClient = new Stripe(env.STRIPE_SECRET_KEY!);

export const auth = betterAuth({
  database: drizzleAdapter(db, {
    provider: "pg",
  }),
  user: {
    changeEmail: {
      enabled: true,
      sendChangeEmailVerification: async ({ newEmail, url }) => {
        await sendEmail({
          to: newEmail,
          subject: "Verify your email change",
          heading: "Verify your email change",
          text: `Click the link to verify: ${url}`,
        });
      },
    },
  plugins: [
    genericOAuth({
      config: [
        {
          providerId: "planning-center",
          authentication: "basic",
          clientId: env.PCO_CLIENT_ID,
          clientSecret: env.PCO_SECRET,
          authorizationUrl:
            "https://api.planningcenteronline.com/oauth/authorize",
          scopes: ["people", "services"],
          tokenUrl: "https://api.planningcenteronline.com/oauth/token",
          userInfoUrl: "https://api.planningcenteronline.com/people/v2/me",
          redirectURI: `${env.BETTER_AUTH_URL}/api/auth/oauth2/callback/planning-center`,
          getUserInfo: async (tokens) => {
            const fetchUserInfoFromCustomProvider = await fetch(
              "https://api.planningcenteronline.com/people/v2/me",
              {
                headers: {
                  Authorization: `Bearer ${tokens.accessToken}`,
                  Accept: "application/json",
                },
              }
            );
            const profile = await fetchUserInfoFromCustomProvider.json();

            return {
              id: profile.data.id,
              name: profile.data.attributes.name,
              email: profile.data.attributes.login_identifier,
              image: profile.data.attributes.avatar,
              emailVerified: true,
              createdAt: new Date(),
              updatedAt: new Date(),
              refreshToken: tokens.refreshToken,
            };
          },
          accessType: "oauth2",
        },
      ],
    }),
    organization({
      async sendInvitationEmail(data) {
        await sendEmail({
          to: data.email,
          subject: "You've been invited to join an organization",
          heading: "You've been invited to join an organization",
          text: `Click the link to verify: ${env.BETTER_AUTH_URL}/accept-invitation/${data.id}`,
        });
      },
      allowUserToCreateOrganization: async (user) => {
        return user.id === "P9gs0I3OrgeDJOGSoc0Vn3x3XkVfIng6";
      },
    }),
    openAPI(),
    bearer(),
    adminPlugin({
      impersonationSessionDuration: 60 * 60 * 24 * 7, // 7 days
      ac: ac,
      roles: {
        admin,
        user,
        myCustomRole,
      },
    }),
    twoFactor({
      issuer: "Next Steps Tracker",
      otpOptions: {
        async sendOTP({ user, otp }) {
          await sendEmail({
            to: user.email,
            subject: "Your OTP",
            heading: "Your OTP",
            text: `Your OTP is ${otp}`,
          });
        },
      },
    }),
    nextCookies(),
    stripe({
      stripeClient,
      stripeWebhookSecret: env.STRIPE_WEBHOOK_SECRET!,
      createCustomerOnSignUp: true,
      subscription: {
        enabled: true,
        plans: [
          {
            name: "base",
            priceId: "price_1R0ufIIynnpyi1LQH67gak6T",
          },
          {
            name: "plus",
            priceId: "price_1R0ufJIynnpyi1LQKMn7qOW8",
          },
        ],
      },
    }),
  ],
  emailAndPassword: {
    enabled: true,
    requireEmailVerification: true,
    sendResetPassword: async ({ user, url }) => {
      await sendEmail({
        to: user.email,
        subject: "Reset your password",
        heading: "Reset your password",
        text: `Click the link to reset your password: ${url}`,
      });
    },
  },
  emailVerification: {
    sendOnSignUp: true,
    autoSignInAfterVerification: true,
    sendVerificationEmail: async ({ user, token }) => {
      const verificationUrl = `${env.BETTER_AUTH_URL}/api/auth/verify-email?token=${token}&callbackURL=${env.EMAIL_VERIFICATION_CALLBACK_URL}`;
      await sendEmail({
        to: user.email,
        subject: "Verify your email address",
        heading: "Verify your email address",
        text: `Click the link to verify your email: ${verificationUrl}`,
      });
    },
  },
  account: {
    accountLinking: {
      enabled: true,
      trustedProviders: ["planning-center"],
      allowDifferentEmails: false,
    },
  },
  databaseHooks: {
    session: {
      create: {
        before: async (session) => {
          console.log("Session: ", session);
          const organizationId = await getActiveOrganization(session.userId);
          return {
            data: {
              ...session,
              activeOrganizationId: organizationId,
            },
          };
        },
      },
    },
  },
} satisfies BetterAuthOptions);

export type Session = typeof auth.$Infer.Session;
export type Account = {
  accountId: string;
  createdAt: Date;
  id: string;
  provider: string;
  scopes: Array<string>;
  updatedAt: Date;
};

Additional context

No response

Originally created by @mfragale on GitHub (Apr 25, 2025). Original GitHub issue: https://github.com/better-auth/better-auth/issues/2436 ### Is this suited for github? - [x] Yes, this is suited for github ### To Reproduce 1. Link an existing credential account with a Generic oAuth account using: ``` await client.oauth2.link({ providerId: "planning-center", callbackURL: "/api/planning-center/pco-actions", }); ``` 2. Check DB and see that accessToken and accessTokenExpiresAt where correctly added to the DB but no refreshToken and refreshTokenExpiresAt where added to the DB. ### Current vs. Expected behavior I expected the DB to be populated with accessToken and accessTokenExpiresAt as well as with refreshToken and refreshTokenExpiresAt. ### What version of Better Auth are you using? 1.2.7 ### Provide environment information ```bash - OS: Mac OS 15.4.1 - Browser: Chrome ``` ### Which area(s) are affected? (Select all that apply) Backend ### Auth config (if applicable) ```typescript import { stripe } from "@better-auth/stripe"; import { BetterAuthOptions, betterAuth } from "better-auth"; import { drizzleAdapter } from "better-auth/adapters/drizzle"; import { nextCookies } from "better-auth/next-js"; import { admin as adminPlugin, bearer, genericOAuth, openAPI, organization, twoFactor, } from "better-auth/plugins"; import Stripe from "stripe"; import { sendEmail } from "@/actions/send-emails/route"; import { db } from "@/drizzle/db"; import { env } from "@/env/server"; import { getActiveOrganization } from "@/features/users/db/users"; import { ac, admin, myCustomRole, user } from "@/lib/permissions"; const stripeClient = new Stripe(env.STRIPE_SECRET_KEY!); export const auth = betterAuth({ database: drizzleAdapter(db, { provider: "pg", }), user: { changeEmail: { enabled: true, sendChangeEmailVerification: async ({ newEmail, url }) => { await sendEmail({ to: newEmail, subject: "Verify your email change", heading: "Verify your email change", text: `Click the link to verify: ${url}`, }); }, }, plugins: [ genericOAuth({ config: [ { providerId: "planning-center", authentication: "basic", clientId: env.PCO_CLIENT_ID, clientSecret: env.PCO_SECRET, authorizationUrl: "https://api.planningcenteronline.com/oauth/authorize", scopes: ["people", "services"], tokenUrl: "https://api.planningcenteronline.com/oauth/token", userInfoUrl: "https://api.planningcenteronline.com/people/v2/me", redirectURI: `${env.BETTER_AUTH_URL}/api/auth/oauth2/callback/planning-center`, getUserInfo: async (tokens) => { const fetchUserInfoFromCustomProvider = await fetch( "https://api.planningcenteronline.com/people/v2/me", { headers: { Authorization: `Bearer ${tokens.accessToken}`, Accept: "application/json", }, } ); const profile = await fetchUserInfoFromCustomProvider.json(); return { id: profile.data.id, name: profile.data.attributes.name, email: profile.data.attributes.login_identifier, image: profile.data.attributes.avatar, emailVerified: true, createdAt: new Date(), updatedAt: new Date(), refreshToken: tokens.refreshToken, }; }, accessType: "oauth2", }, ], }), organization({ async sendInvitationEmail(data) { await sendEmail({ to: data.email, subject: "You've been invited to join an organization", heading: "You've been invited to join an organization", text: `Click the link to verify: ${env.BETTER_AUTH_URL}/accept-invitation/${data.id}`, }); }, allowUserToCreateOrganization: async (user) => { return user.id === "P9gs0I3OrgeDJOGSoc0Vn3x3XkVfIng6"; }, }), openAPI(), bearer(), adminPlugin({ impersonationSessionDuration: 60 * 60 * 24 * 7, // 7 days ac: ac, roles: { admin, user, myCustomRole, }, }), twoFactor({ issuer: "Next Steps Tracker", otpOptions: { async sendOTP({ user, otp }) { await sendEmail({ to: user.email, subject: "Your OTP", heading: "Your OTP", text: `Your OTP is ${otp}`, }); }, }, }), nextCookies(), stripe({ stripeClient, stripeWebhookSecret: env.STRIPE_WEBHOOK_SECRET!, createCustomerOnSignUp: true, subscription: { enabled: true, plans: [ { name: "base", priceId: "price_1R0ufIIynnpyi1LQH67gak6T", }, { name: "plus", priceId: "price_1R0ufJIynnpyi1LQKMn7qOW8", }, ], }, }), ], emailAndPassword: { enabled: true, requireEmailVerification: true, sendResetPassword: async ({ user, url }) => { await sendEmail({ to: user.email, subject: "Reset your password", heading: "Reset your password", text: `Click the link to reset your password: ${url}`, }); }, }, emailVerification: { sendOnSignUp: true, autoSignInAfterVerification: true, sendVerificationEmail: async ({ user, token }) => { const verificationUrl = `${env.BETTER_AUTH_URL}/api/auth/verify-email?token=${token}&callbackURL=${env.EMAIL_VERIFICATION_CALLBACK_URL}`; await sendEmail({ to: user.email, subject: "Verify your email address", heading: "Verify your email address", text: `Click the link to verify your email: ${verificationUrl}`, }); }, }, account: { accountLinking: { enabled: true, trustedProviders: ["planning-center"], allowDifferentEmails: false, }, }, databaseHooks: { session: { create: { before: async (session) => { console.log("Session: ", session); const organizationId = await getActiveOrganization(session.userId); return { data: { ...session, activeOrganizationId: organizationId, }, }; }, }, }, }, } satisfies BetterAuthOptions); export type Session = typeof auth.$Infer.Session; export type Account = { accountId: string; createdAt: Date; id: string; provider: string; scopes: Array<string>; updatedAt: Date; }; ``` ### Additional context _No response_
GiteaMirror added the locked label 2026-04-13 04:34:26 -05:00
Author
Owner

@Kinfe123 commented on GitHub (Apr 25, 2025):

can you make sure your oauth server for the token endpoint is returning those tokens ?

<!-- gh-comment-id:2830706326 --> @Kinfe123 commented on GitHub (Apr 25, 2025): can you make sure your oauth server for the token endpoint is returning those tokens ?
Author
Owner

@mfragale commented on GitHub (Apr 25, 2025):

Yes, it is. In the case of this provider, according to their docs (https://developer.planning.center/docs/#/overview/authentication) the tokens are being returned and when I console.log the endpoint return from them I see the tokens there.

<!-- gh-comment-id:2831347344 --> @mfragale commented on GitHub (Apr 25, 2025): Yes, it is. In the case of this provider, according to their docs (https://developer.planning.center/docs/#/overview/authentication) the tokens are being returned and when I console.log the endpoint return from them I see the tokens there.
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: github-starred/better-auth#9192