Social Account link Nextjs

Hello, not sure where to put this

Below is my current working setup for Social account “Client” link , question if something can be simplified or do i need additional checks for security?

Step1: user Signin with social account

Auth0 → Actions → Login / Post Login

exports.onExecutePostLogin = async (event, api) => {
  const id = event.user.identities && event.user.identities[0];

  // Only run for Google; skip DB and other providers
  if (!id || id.provider !== "google-oauth2") return;
  if (!event.user.email || !event.user.email_verified) return;

  const alreadyLinkedToDB = (event.user.identities || []).some(
    (i) => i.provider === "auth0",
  );
  if (alreadyLinkedToDB) return;

  let users = [];
  try {
    const token = await getMgmtToken(event);
    users = await getUsersByEmail(event.user.email, token, event);
  } catch (e) {
    return;
  }

  if (!users.length) return;

  // Find an Auth0 DB (credentials) user with same email
  const dbUser = (users || []).find((u) =>
    (u.identities || []).some((i) => i.provider === "auth0"),
  );
  if (
    !dbUser?.email ||
    !dbUser?.email_verified ||
    dbUser.email.toLowerCase() !== event.user.email.toLowerCase()
  ) {
    console.log("Skip: email mismatch or unverified on primary");
    return;
  }

  // Pause login and send to your consent page with the transaction state
  if (event.transaction) {
    const sessionToken = api.redirect.encodeToken({
      payload: {
        email: event.user.email,
        secondary_user_id: event.user.user_id, // Social account user ID for JWT validation
      },
      secret: event.secrets.ACTION_SHARED_SECRET,
      expiresInSeconds: 180, // 3 minutes
    });

    api.redirect.sendUserTo("http://localhost:3000/auth/link-account", {
      query: {
        session_token: sessionToken,
      },
    });
  }

  async function getUsersByEmail(email, mgmtToken, event) {
    const controller = new AbortController();
    const id = setTimeout(() => controller.abort(), 5000);
    try {
      const r = await fetch(
        `https://${event.secrets.AUTH0_DOMAIN}/api/v2/users-by-email?email=${encodeURIComponent(email)}`,
        {
          headers: { Authorization: `Bearer ${mgmtToken}` },
          signal: controller.signal,
        },
      );
      // @ts-ignore
      return r.ok ? r.json() : [];
    } catch (e) {
      console.error(e);
      return [];
    } finally {
      clearTimeout(id);
    }
  }
};


exports.onContinuePostLogin = async (event, api) => {
  const controller = new AbortController();
  const id = setTimeout(() => controller.abort(), 5000);

  try {
    const payload = api.redirect.validateToken({
      secret: event.secrets.ACTION_SHARED_SECRET,
      tokenParameterName: "link_token",
    });

    const primaryUserId = String(payload.primary_user_id || "");
    if (!primaryUserId) return api.access.deny("bad_primary");

    const userId = event.user.identities?.[0];
    if (!userId || userId.provider !== "google-oauth2") {
      return api.access.deny("Not a Google continue transaction");
    }

    // Perform the account link via Management API
    const provider = "google-oauth2";
    const secId = userId.user_id;
    const token = await getMgmtToken(event);

    const resp = await fetch(
      `https://${event.secrets.AUTH0_DOMAIN}/api/v2/users/${encodeURIComponent(primaryUserId)}/identities`,
      {
        method: "POST",
        headers: {
          Authorization: `Bearer ${token}`,
          "content-type": "application/json",
        },
        body: JSON.stringify({ provider, user_id: secId }),
        signal: controller.signal,
      },
    );

    // Already linked
    // @ts-ignore
    if (resp.status === 409) {
      api.authentication.setPrimaryUser(primaryUserId);
      return;
    }

    // @ts-ignore
    if (!resp.ok) {
    // @ts-ignore
      const errorText = await resp.text();
      console.error("Failed to link accounts:", errorText);
      return api.access.deny(`Account linking failed: ${errorText}`);
    }

    // Make sure the session finishes as the PRIMARY user
    api.authentication.setPrimaryUser(primaryUserId);
  } catch (e) {
    console.error("Failed to link accounts", e);
  } finally {
    clearTimeout(id);
  }
};

async function getMgmtToken(event) {
  const controller = new AbortController();
  const id = setTimeout(() => controller.abort(), 5000);
  try {
    const r = await fetch(`https://${event.secrets.AUTH0_DOMAIN}/oauth/token`, {
      method: "POST",
      headers: { "content-type": "application/json" },
      body: JSON.stringify({
        grant_type: "client_credentials",
        client_id: event.secrets.CLIENT_ID,
        client_secret: event.secrets.CLIENT_SECRET,
        audience: `https://${event.secrets.AUTH0_DOMAIN}/api/v2/`,
      }),
      signal: controller.signal,
    });

    // @ts-ignore
    if (!r.ok) throw new Error("Mgmt token failed");
    // @ts-ignore
    const j = await r.json();
    return j.access_token;
  } catch (e) {
    return undefined;
  } finally {
    clearTimeout(id);
  }
}

Step 2:

My page.tsx with continue/cancel buttons

export default async function LinkConsent({
  searchParams,
}: {
  searchParams: Promise<{
    state?: string;
    session_token?: string;
  }>;
}) {
  const { state, session_token } = await searchParams;

  if (!state || !session_token) {
    return <div className="p-6">Invalid state, failed to link account</div>;
  }

return (
 <a
            href={`${API_AUTH_LINK_START}?state=${state}&session_token=${session_token}`}
          >
            Continue
          </a>
 );
}

Step 3: when user clicks “Continue”, request must be GET for redirect to process correctly

export async function GET(request: Request) {
  const url = new URL(request.url);
  const state = url.searchParams.get("state") || "";
  const sessionToken = url.searchParams.get("session_token") || "";

  const secret = new TextEncoder().encode(process.env.actionSharedSecret);

  let payload: {
    email: string;
    secondary_user_id: string;
  };
  try {
    const verifyData = await jwtVerify(sessionToken, secret, {
      algorithms: ["HS256"],
    });
    payload = verifyData.payload as {
      email: string;
      secondary_user_id: string;
    };
  } catch (e) {
    notFound();
  }

  if (!state || !sessionToken || !payload.email || !payload.secondary_user_id) {
    return new NextResponse("Bad request", { status: 400 });
  }

  const nonce = randomUUID();
  savePending(nonce, {
    continueState: state,
    sessionToken,
    secondaryUserId: payload.secondary_user_id,
  });

  const redirectUrl = new URL(API_AUTH_LINK_FINISH, process.env.appDomain);
  redirectUrl.searchParams.set("nonce", nonce);

  const loginUrl = new URL(API_LOGIN, process.env.appDomain);
  loginUrl.searchParams.set("returnTo", redirectUrl.toString());
  loginUrl.searchParams.set("connection", "Username-Password-Authentication");
  loginUrl.searchParams.set("prompt", "login");
  loginUrl.searchParams.set("max_age", "0");
  loginUrl.searchParams.set("login_hint", payload.email);

  return NextResponse.redirect(loginUrl, { status: 302 });

Step 4: After user confirms password for creds user

export async function GET(req: Request) {
  const authResult = //check if creds user authenticated
  if (!authResult) return new NextResponse("No DB session", { status: 401 });

  const url = new URL(req.url);
  const nonce = url.searchParams.get("nonce") || "";
  if (!nonce) return new NextResponse("Missing nonce", { status: 400 });

  const pending = loadPending(nonce);
  if (!pending) return new NextResponse("Link expired", { status: 400 });

  const { continueState, sessionToken, secondaryUserId } = pending;
  clearPending(nonce);
  if (!continueState || !sessionToken || !secondaryUserId)
    return new NextResponse("Missing state", { status: 400 });

  const config = initConfig();

  const secret = new TextEncoder().encode(process.env.actionSharedSecret);

  const auth0Domain = process.env.domain;
  const auth0Issuer = auth0Domain.endsWith("/")
    ? auth0Domain
    : `${auth0Domain}/`;

  const linkToken = await new SignJWT({
    primary_user_id: authResult.sub,
    state: continueState,
  })
    .setProtectedHeader({ alg: "HS256", typ: "JWT" })
    .setSubject(secondaryUserId)
    .setIssuer(auth0Issuer)
    .setAudience(auth0Issuer)
    .setExpirationTime("1m")
    .setIssuedAt() 
    .sign(secret);

  const continueSocialLoginFlow = new URL(
    "/continue",
    process.env.AUTH0_ISSUER_BASE_URL,
  );
  continueSocialLoginFlow.searchParams.set("state", continueState);
  continueSocialLoginFlow.searchParams.set("link_token", linkToken);
  continueSocialLoginFlow.searchParams.set("session_token", sessionToken);

  return NextResponse.redirect(continueSocialLoginFlow, { status: 302 });
}

After that “Continue” at action is triggered and account becomes linked.

Hi @vadim

As far as I have checked in your code, I do not believe that anything else can be simplified regarding the matter. You action appears to be significantly shorter than what a typical account linking action would look like.

You appear to handle any necessary errors accordingly and also you require your users to retype their password in order to move forward with the linking request. My personal suggestion would be to have Step-Up Authentication here in which you would ask for MFA instead of having them simply confirm the password. This would help reducing the code for the password check either by enforcing it when the account linking is triggered or to provide access to the page/feature itself. Of course, this totally depends on your use case and if you even want to enforce your users to be enrolled into an MFA factor.

If you have any other questions, let me know!

Kind Regards,
Nik