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.