mirror of
https://github.com/better-auth/better-auth.git
synced 2026-10-18 11:32:15 -05:00
fix(oauth-proxy): support social account linking (#11268)
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"better-auth": patch
|
||||
---
|
||||
|
||||
Support linking social accounts through the OAuth Proxy plugin.
|
||||
@@ -1,7 +1,6 @@
|
||||
import { createAuthEndpoint } from "@better-auth/core/api";
|
||||
import type { AccountKey } from "@better-auth/core/db";
|
||||
import type { OAuth2Tokens } from "@better-auth/core/oauth2";
|
||||
import { mergeScopes } from "@better-auth/core/oauth2";
|
||||
import { safeJSONParse } from "@better-auth/core/utils/json";
|
||||
import { appendQueryParams } from "@better-auth/core/utils/url";
|
||||
import * as z from "zod";
|
||||
@@ -16,18 +15,17 @@ import {
|
||||
OAUTH_CALLBACK_ERROR_CODES,
|
||||
} from "../../oauth2/errors";
|
||||
import {
|
||||
applyUpdateUserInfoOnLink,
|
||||
handleOAuthUserInfo,
|
||||
linkOAuthAccount,
|
||||
} from "../../oauth2/link-account";
|
||||
import {
|
||||
generateIdTokenNonce,
|
||||
generateState,
|
||||
parseState,
|
||||
} from "../../oauth2/state";
|
||||
import { getOAuthCallbackPath, setTokenUtil } from "../../oauth2/utils";
|
||||
import { getOAuthCallbackPath } from "../../oauth2/utils";
|
||||
import { HIDE_METADATA } from "../../utils/hide-metadata";
|
||||
import { isAPIError } from "../../utils/is-api-error";
|
||||
import { assertValidUserInfo } from "../../utils/validate-user-info";
|
||||
|
||||
const schema = z.object({
|
||||
code: z.string().optional(),
|
||||
@@ -261,112 +259,30 @@ export const callbackOAuth = createAuthEndpoint(
|
||||
c.context.logger.error("No callback URL found");
|
||||
throw redirectOnError(OAUTH_CALLBACK_ERROR_CODES.NO_CALLBACK_URL);
|
||||
}
|
||||
const accountData = {
|
||||
...accountKey,
|
||||
...tokens,
|
||||
scope: tokens.scopes?.join(","),
|
||||
};
|
||||
|
||||
if (link) {
|
||||
// Link-account creates no user row, so the gate runs here rather than
|
||||
// inside createUser.
|
||||
try {
|
||||
await assertValidUserInfo(c, {
|
||||
user: {
|
||||
...userInfo,
|
||||
id: link.userId,
|
||||
email: userInfo.email ?? undefined,
|
||||
},
|
||||
source: {
|
||||
action: "link-account",
|
||||
method: "oauth",
|
||||
oauth: {
|
||||
providerId: provider.id,
|
||||
profile: providerProfile,
|
||||
},
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
if (isAPIError(e) && e.body?.code) {
|
||||
throw redirectOnError(e.body.code, e.body.message);
|
||||
}
|
||||
throw e;
|
||||
const linkResult = await linkOAuthAccount(c, {
|
||||
link,
|
||||
userInfo,
|
||||
account: accountData,
|
||||
profile: providerProfile,
|
||||
scopes: tokens.scopes,
|
||||
});
|
||||
if (!linkResult.linked) {
|
||||
return redirectOnError(linkResult.error.code, linkResult.error.message);
|
||||
}
|
||||
const isTrustedProvider = c.context.trustedProviders.includes(
|
||||
provider.id,
|
||||
);
|
||||
if (
|
||||
(!isTrustedProvider && !userInfo.emailVerified) ||
|
||||
c.context.options.account?.accountLinking?.enabled === false
|
||||
) {
|
||||
c.context.logger.error("Unable to link account - untrusted provider");
|
||||
return redirectOnError(
|
||||
OAUTH_CALLBACK_ERROR_CODES.UNABLE_TO_LINK_ACCOUNT,
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
userInfo.email?.toLowerCase() !== link.email.toLowerCase() &&
|
||||
c.context.options.account?.accountLinking?.allowDifferentEmails !== true
|
||||
) {
|
||||
return redirectOnError(OAUTH_CALLBACK_ERROR_CODES.EMAIL_DOES_NOT_MATCH);
|
||||
}
|
||||
|
||||
const existingAccount =
|
||||
await c.context.internalAdapter.findAccountByKey(accountKey);
|
||||
|
||||
if (existingAccount) {
|
||||
if (existingAccount.userId.toString() !== link.userId.toString()) {
|
||||
return redirectOnError(
|
||||
OAUTH_CALLBACK_ERROR_CODES.ACCOUNT_ALREADY_LINKED_TO_DIFFERENT_USER,
|
||||
);
|
||||
}
|
||||
const mergedScope = mergeScopes(existingAccount.scope, tokens.scopes);
|
||||
const updateData = Object.fromEntries(
|
||||
Object.entries({
|
||||
providerId: provider.id,
|
||||
accessToken: await setTokenUtil(tokens.accessToken, c.context),
|
||||
refreshToken: await setTokenUtil(tokens.refreshToken, c.context),
|
||||
idToken: tokens.idToken,
|
||||
accessTokenExpiresAt: tokens.accessTokenExpiresAt,
|
||||
refreshTokenExpiresAt: tokens.refreshTokenExpiresAt,
|
||||
scope: mergedScope || undefined,
|
||||
}).filter(([_, value]) => value !== undefined),
|
||||
);
|
||||
await c.context.internalAdapter.updateAccount(
|
||||
existingAccount.id,
|
||||
updateData,
|
||||
);
|
||||
} else {
|
||||
const newAccount = await c.context.internalAdapter.createAccount({
|
||||
userId: link.userId,
|
||||
...accountKey,
|
||||
...tokens,
|
||||
accessToken: await setTokenUtil(tokens.accessToken, c.context),
|
||||
refreshToken: await setTokenUtil(tokens.refreshToken, c.context),
|
||||
scope: tokens.scopes?.join(","),
|
||||
});
|
||||
if (!newAccount) {
|
||||
return redirectOnError(
|
||||
OAUTH_CALLBACK_ERROR_CODES.UNABLE_TO_LINK_ACCOUNT,
|
||||
);
|
||||
}
|
||||
}
|
||||
await applyUpdateUserInfoOnLink(c, link.userId, userInfo);
|
||||
let toRedirectTo: string;
|
||||
try {
|
||||
const url = callbackURL;
|
||||
toRedirectTo = url.toString();
|
||||
} catch {
|
||||
toRedirectTo = callbackURL;
|
||||
}
|
||||
throw c.redirect(toRedirectTo);
|
||||
throw c.redirect(callbackURL);
|
||||
}
|
||||
|
||||
if (!userInfo.email) {
|
||||
c.context.logger.error(missingEmailLogMessage(provider.id));
|
||||
return redirectOnError(OAUTH_CALLBACK_ERROR_CODES.EMAIL_NOT_FOUND);
|
||||
}
|
||||
const accountData = {
|
||||
...accountKey,
|
||||
...tokens,
|
||||
scope: tokens.scopes?.join(","),
|
||||
};
|
||||
let result: Awaited<ReturnType<typeof handleOAuthUserInfo>>;
|
||||
try {
|
||||
result = await handleOAuthUserInfo(c, {
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
} from "@better-auth/core/context";
|
||||
import { isDevelopment } from "@better-auth/core/env";
|
||||
import { APIError } from "@better-auth/core/error";
|
||||
import { mergeScopes } from "@better-auth/core/oauth2";
|
||||
import { createEmailVerificationToken } from "../api";
|
||||
import { setAccountCookie } from "../cookies/session-store";
|
||||
import { parseAdditionalUserInputFromProviderProfile } from "../db";
|
||||
@@ -17,6 +18,143 @@ import { assertValidUserInfo } from "../utils/validate-user-info";
|
||||
import { OAUTH_CALLBACK_ERROR_CODES, redirectOnError } from "./errors";
|
||||
import { setTokenUtil } from "./utils";
|
||||
|
||||
type OAuthAccountData = Omit<
|
||||
Account,
|
||||
"id" | "userId" | "createdAt" | "updatedAt"
|
||||
>;
|
||||
|
||||
/**
|
||||
* Provider profile a freshly linked account may copy onto the local user.
|
||||
* `email` and `emailVerified` are identity anchors and are stripped before
|
||||
* the remaining fields are written. Provider identity is resolved separately
|
||||
* from the raw profile through the provider's account-key contract.
|
||||
*/
|
||||
type LinkedProviderProfile = {
|
||||
name?: string | undefined;
|
||||
email?: string | null | undefined;
|
||||
emailVerified?: boolean | undefined;
|
||||
image?: string | null | undefined;
|
||||
};
|
||||
|
||||
interface LinkOAuthAccountOptions {
|
||||
link: {
|
||||
userId: string;
|
||||
email: string;
|
||||
};
|
||||
userInfo: LinkedProviderProfile;
|
||||
account: OAuthAccountData;
|
||||
profile: Record<string, unknown>;
|
||||
scopes?: string[] | undefined;
|
||||
}
|
||||
|
||||
interface LinkOAuthAccountFailure {
|
||||
linked: false;
|
||||
error: {
|
||||
code: string;
|
||||
message?: string | undefined;
|
||||
};
|
||||
}
|
||||
|
||||
type LinkOAuthAccountResult = { linked: true } | LinkOAuthAccountFailure;
|
||||
|
||||
function linkOAuthAccountFailure(
|
||||
code: string,
|
||||
message?: string,
|
||||
): LinkOAuthAccountFailure {
|
||||
return { linked: false, error: { code, message } };
|
||||
}
|
||||
|
||||
export async function linkOAuthAccount(
|
||||
c: GenericEndpointContext,
|
||||
{ link, userInfo, account, profile, scopes }: LinkOAuthAccountOptions,
|
||||
): Promise<LinkOAuthAccountResult> {
|
||||
try {
|
||||
await assertValidUserInfo(c, {
|
||||
user: {
|
||||
...userInfo,
|
||||
id: link.userId,
|
||||
email: userInfo.email ?? undefined,
|
||||
},
|
||||
source: {
|
||||
action: "link-account",
|
||||
method: "oauth",
|
||||
oauth: {
|
||||
providerId: account.providerId,
|
||||
profile,
|
||||
},
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
if (!isAPIError(error) || !error.body?.code) throw error;
|
||||
return linkOAuthAccountFailure(error.body.code, error.body.message);
|
||||
}
|
||||
|
||||
const isTrustedProvider = c.context.trustedProviders.includes(
|
||||
account.providerId,
|
||||
);
|
||||
if (
|
||||
(!isTrustedProvider && !userInfo.emailVerified) ||
|
||||
c.context.options.account?.accountLinking?.enabled === false
|
||||
) {
|
||||
c.context.logger.error("Unable to link account - untrusted provider");
|
||||
return linkOAuthAccountFailure(
|
||||
OAUTH_CALLBACK_ERROR_CODES.UNABLE_TO_LINK_ACCOUNT,
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
userInfo.email?.toLowerCase() !== link.email.toLowerCase() &&
|
||||
c.context.options.account?.accountLinking?.allowDifferentEmails !== true
|
||||
) {
|
||||
return linkOAuthAccountFailure(
|
||||
OAUTH_CALLBACK_ERROR_CODES.EMAIL_DOES_NOT_MATCH,
|
||||
);
|
||||
}
|
||||
|
||||
const existingAccount =
|
||||
await c.context.internalAdapter.findAccountByKey(account);
|
||||
|
||||
if (existingAccount) {
|
||||
if (existingAccount.userId.toString() !== link.userId.toString()) {
|
||||
return linkOAuthAccountFailure(
|
||||
OAUTH_CALLBACK_ERROR_CODES.ACCOUNT_ALREADY_LINKED_TO_DIFFERENT_USER,
|
||||
);
|
||||
}
|
||||
const mergedScope = mergeScopes(existingAccount.scope, scopes);
|
||||
const updateData = Object.fromEntries(
|
||||
Object.entries({
|
||||
providerId: account.providerId,
|
||||
accessToken: await setTokenUtil(account.accessToken, c.context),
|
||||
refreshToken: await setTokenUtil(account.refreshToken, c.context),
|
||||
idToken: account.idToken,
|
||||
accessTokenExpiresAt: account.accessTokenExpiresAt,
|
||||
refreshTokenExpiresAt: account.refreshTokenExpiresAt,
|
||||
scope: mergedScope || undefined,
|
||||
}).filter(([_, value]) => value !== undefined),
|
||||
);
|
||||
await c.context.internalAdapter.updateAccount(
|
||||
existingAccount.id,
|
||||
updateData,
|
||||
);
|
||||
} else {
|
||||
const newAccount = await c.context.internalAdapter.createAccount({
|
||||
userId: link.userId,
|
||||
...account,
|
||||
accessToken: await setTokenUtil(account.accessToken, c.context),
|
||||
refreshToken: await setTokenUtil(account.refreshToken, c.context),
|
||||
scope: scopes?.join(",") ?? account.scope,
|
||||
});
|
||||
if (!newAccount) {
|
||||
return linkOAuthAccountFailure(
|
||||
OAUTH_CALLBACK_ERROR_CODES.UNABLE_TO_LINK_ACCOUNT,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await applyUpdateUserInfoOnLink(c, link.userId, userInfo);
|
||||
return { linked: true };
|
||||
}
|
||||
|
||||
// TODO(#9124): v2 widens `User.email` to nullable; every `userInfo.email.toLowerCase()`
|
||||
// call below needs null-safety before account recognition can be fully
|
||||
// independent from email-based implicit linking.
|
||||
@@ -24,7 +162,7 @@ export async function handleOAuthUserInfo(
|
||||
c: GenericEndpointContext,
|
||||
opts: {
|
||||
userInfo: Omit<User, "createdAt" | "updatedAt">;
|
||||
account: Omit<Account, "id" | "userId" | "createdAt" | "updatedAt">;
|
||||
account: OAuthAccountData;
|
||||
callbackURL?: string | undefined;
|
||||
disableSignUp?: boolean | undefined;
|
||||
overrideUserInfo?: boolean | undefined;
|
||||
@@ -569,19 +707,6 @@ async function dispatchVerificationEmail(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Provider profile a freshly linked account may copy onto the local user.
|
||||
* `email` and `emailVerified` are identity anchors and are stripped before
|
||||
* the remaining fields are written. Provider identity is resolved separately
|
||||
* from the raw profile through the provider's account-key contract.
|
||||
*/
|
||||
type LinkedProviderProfile = {
|
||||
name?: string | undefined;
|
||||
email?: string | null | undefined;
|
||||
emailVerified?: boolean | undefined;
|
||||
image?: string | null | undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
* Apply the `account.accountLinking.updateUserInfoOnLink` policy: when enabled,
|
||||
* copy the freshly linked provider's profile onto the local user, matching the
|
||||
|
||||
@@ -24,7 +24,10 @@ import {
|
||||
toOAuthProfileRecord,
|
||||
} from "../../oauth2/account-key";
|
||||
import { redirectOnError } from "../../oauth2/errors";
|
||||
import { handleOAuthUserInfo } from "../../oauth2/link-account";
|
||||
import {
|
||||
handleOAuthUserInfo,
|
||||
linkOAuthAccount,
|
||||
} from "../../oauth2/link-account";
|
||||
import { getOAuthCallbackPath } from "../../oauth2/utils";
|
||||
import type { StateData } from "../../state";
|
||||
import { parseGenericState } from "../../state";
|
||||
@@ -118,6 +121,7 @@ const passthroughPayloadSchema = z.looseObject({
|
||||
}).shape,
|
||||
),
|
||||
profile: z.record(z.string(), z.unknown()).optional(),
|
||||
scopes: z.array(z.string()).optional(),
|
||||
state: z.string().min(1),
|
||||
callbackURL: z.string().min(1),
|
||||
newUserURL: z.string().optional(),
|
||||
@@ -234,7 +238,24 @@ export const oAuthProxy = <O extends OAuthProxyOptions>(opts?: O) => {
|
||||
if (!stateData) {
|
||||
throw redirectOnError(ctx, errorURL, "state_mismatch");
|
||||
}
|
||||
|
||||
if (stateData.link) {
|
||||
const linkResult = await linkOAuthAccount(ctx, {
|
||||
link: stateData.link,
|
||||
userInfo: payload.userInfo,
|
||||
account: payload.account,
|
||||
profile: payload.profile ?? {},
|
||||
scopes: payload.scopes ?? payload.account.scope?.split(","),
|
||||
});
|
||||
if (!linkResult.linked) {
|
||||
throw redirectOnError(
|
||||
ctx,
|
||||
errorURL,
|
||||
linkResult.error.code,
|
||||
linkResult.error.message,
|
||||
);
|
||||
}
|
||||
throw ctx.redirect(payload.callbackURL);
|
||||
}
|
||||
let result: Awaited<ReturnType<typeof handleOAuthUserInfo>>;
|
||||
try {
|
||||
result = await handleOAuthUserInfo(ctx, {
|
||||
@@ -339,7 +360,10 @@ export const oAuthProxy = <O extends OAuthProxyOptions>(opts?: O) => {
|
||||
before: [
|
||||
{
|
||||
matcher(context) {
|
||||
return !!context.path?.startsWith("/sign-in/social");
|
||||
return (
|
||||
!!context.path?.startsWith("/sign-in/social") ||
|
||||
context.path === "/link-social"
|
||||
);
|
||||
},
|
||||
handler: createAuthMiddleware(async (ctx) => {
|
||||
const skipProxy = checkSkipProxy(ctx, opts);
|
||||
@@ -562,6 +586,7 @@ export const oAuthProxy = <O extends OAuthProxyOptions>(opts?: O) => {
|
||||
emailVerified: userInfo.emailVerified,
|
||||
},
|
||||
profile: providerProfile,
|
||||
scopes: tokens.scopes,
|
||||
account: {
|
||||
...accountKey,
|
||||
accessToken: tokens.accessToken,
|
||||
@@ -597,7 +622,10 @@ export const oAuthProxy = <O extends OAuthProxyOptions>(opts?: O) => {
|
||||
after: [
|
||||
{
|
||||
matcher(context) {
|
||||
return !!context.path?.startsWith("/sign-in/social");
|
||||
return (
|
||||
!!context.path?.startsWith("/sign-in/social") ||
|
||||
context.path === "/link-social"
|
||||
);
|
||||
},
|
||||
handler: createAuthMiddleware(async (ctx) => {
|
||||
const skipProxy = checkSkipProxy(ctx, opts);
|
||||
@@ -616,7 +644,7 @@ export const oAuthProxy = <O extends OAuthProxyOptions>(opts?: O) => {
|
||||
}
|
||||
|
||||
const { url: providerURL } = signInResponse;
|
||||
if (typeof providerURL !== "string") {
|
||||
if (typeof providerURL !== "string" || providerURL.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -126,6 +126,205 @@ describe("oauth-proxy", async () => {
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* @see https://github.com/better-auth/better-auth/issues/9390
|
||||
*/
|
||||
describe("account linking", () => {
|
||||
const proxySecret = "shared-oauth-proxy-secret";
|
||||
const socialProviders = {
|
||||
google: {
|
||||
clientId: "test",
|
||||
clientSecret: "test",
|
||||
verifyIdToken: async () => true,
|
||||
},
|
||||
};
|
||||
|
||||
async function createAccountLinkingFlow() {
|
||||
const preview = await getTestInstance({
|
||||
baseURL: "http://preview.example.com",
|
||||
secret: "preview-main-secret",
|
||||
plugins: [
|
||||
oAuthProxy({
|
||||
productionURL: "http://localhost:3000",
|
||||
secret: proxySecret,
|
||||
}),
|
||||
],
|
||||
socialProviders,
|
||||
account: {
|
||||
accountLinking: { allowDifferentEmails: true },
|
||||
},
|
||||
});
|
||||
const production = await getTestInstance(
|
||||
{
|
||||
baseURL: "http://localhost:3000",
|
||||
secret: "production-main-secret",
|
||||
plugins: [oAuthProxy({ secret: proxySecret })],
|
||||
socialProviders,
|
||||
},
|
||||
{ disableTestUser: true },
|
||||
);
|
||||
const { user, headers } = await preview.signInWithTestUser();
|
||||
|
||||
async function completeLink(accessToken: string, scope: string) {
|
||||
let tokenRedirectURI: string | null = null;
|
||||
server.use(
|
||||
http.post(
|
||||
"https://oauth2.googleapis.com/token",
|
||||
async ({ request }) => {
|
||||
const body = await request.formData();
|
||||
const redirectURI = body.get("redirect_uri");
|
||||
tokenRedirectURI =
|
||||
typeof redirectURI === "string" ? redirectURI : null;
|
||||
return HttpResponse.json({
|
||||
access_token: accessToken,
|
||||
refresh_token: "test",
|
||||
id_token: testIdToken,
|
||||
expires_in: 3600,
|
||||
refresh_token_expires_in: 86400,
|
||||
scope,
|
||||
});
|
||||
},
|
||||
),
|
||||
);
|
||||
const link = await preview.client.linkSocial(
|
||||
{
|
||||
provider: "google",
|
||||
callbackURL: "/settings",
|
||||
},
|
||||
{ headers },
|
||||
);
|
||||
const authorizationURL = link.data?.url;
|
||||
if (!authorizationURL) throw new Error("Authorization URL is missing");
|
||||
const authorization = new URL(authorizationURL);
|
||||
const authorizationRedirectURI =
|
||||
authorization.searchParams.get("redirect_uri");
|
||||
const state = authorization.searchParams.get("state");
|
||||
if (!state) throw new Error("OAuth state is missing");
|
||||
|
||||
let proxyCallbackURL: string | null = null;
|
||||
await production.client.$fetch(
|
||||
`/callback/google?code=test&state=${encodeURIComponent(state)}`,
|
||||
{
|
||||
onError(context) {
|
||||
proxyCallbackURL = context.response.headers.get("location");
|
||||
},
|
||||
},
|
||||
);
|
||||
if (!proxyCallbackURL) throw new Error("Proxy callback URL is missing");
|
||||
|
||||
const proxyCallback = new URL(proxyCallbackURL);
|
||||
const callbackPath = `${proxyCallback.pathname.replace(
|
||||
"/api/auth",
|
||||
"",
|
||||
)}${proxyCallback.search}`;
|
||||
let finalRedirect: string | null = null;
|
||||
await preview.client.$fetch(callbackPath, {
|
||||
headers,
|
||||
onError(context) {
|
||||
finalRedirect = context.response.headers.get("location");
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
authorizationRedirectURI,
|
||||
finalRedirect,
|
||||
tokenRedirectURI,
|
||||
};
|
||||
}
|
||||
|
||||
return { completeLink, preview, user };
|
||||
}
|
||||
|
||||
it("creates and updates the provider account for the initiating user", async () => {
|
||||
const { completeLink, preview, user } = await createAccountLinkingFlow();
|
||||
const previewContext = await preview.auth.$context;
|
||||
const sessionsBefore = await previewContext.adapter.findMany<{
|
||||
id: string;
|
||||
}>({
|
||||
model: "session",
|
||||
where: [{ field: "userId", value: user.id }],
|
||||
});
|
||||
const firstLink = await completeLink(
|
||||
"initial-access-token",
|
||||
"openid profile",
|
||||
);
|
||||
|
||||
const accountsAfterFirstLink =
|
||||
await previewContext.internalAdapter.findAccounts(user.id);
|
||||
const firstGoogleAccount = accountsAfterFirstLink.find(
|
||||
(account) => account.providerId === "google",
|
||||
);
|
||||
expect(firstLink.authorizationRedirectURI).toBe(
|
||||
"http://localhost:3000/api/auth/callback/google",
|
||||
);
|
||||
expect(firstLink.tokenRedirectURI).toBe(
|
||||
firstLink.authorizationRedirectURI,
|
||||
);
|
||||
expect(firstLink.finalRedirect).toBe("/settings");
|
||||
expect(firstGoogleAccount?.scope).toBe("openid,profile");
|
||||
|
||||
const secondLink = await completeLink(
|
||||
"updated-access-token",
|
||||
"https://www.googleapis.com/auth/drive.readonly",
|
||||
);
|
||||
const accountsAfterSecondLink =
|
||||
await previewContext.internalAdapter.findAccounts(user.id);
|
||||
const googleAccounts = accountsAfterSecondLink.filter(
|
||||
(account) => account.providerId === "google",
|
||||
);
|
||||
const sessionsAfter = await previewContext.adapter.findMany<{
|
||||
id: string;
|
||||
}>({
|
||||
model: "session",
|
||||
where: [{ field: "userId", value: user.id }],
|
||||
});
|
||||
|
||||
expect(secondLink.finalRedirect).toBe("/settings");
|
||||
expect(googleAccounts).toHaveLength(1);
|
||||
expect(googleAccounts[0]?.accessToken).toBe("updated-access-token");
|
||||
expect(googleAccounts[0]?.scope?.split(",").sort()).toEqual([
|
||||
"https://www.googleapis.com/auth/drive.readonly",
|
||||
"openid",
|
||||
"profile",
|
||||
]);
|
||||
expect(sessionsAfter.map((session) => session.id)).toEqual(
|
||||
sessionsBefore.map((session) => session.id),
|
||||
);
|
||||
});
|
||||
|
||||
it("links an account directly with an ID token", async () => {
|
||||
const { auth, client, signInWithTestUser } = await getTestInstance({
|
||||
baseURL: "http://preview.example.com",
|
||||
plugins: [oAuthProxy({ productionURL: "http://localhost:3000" })],
|
||||
socialProviders,
|
||||
account: {
|
||||
accountLinking: { allowDifferentEmails: true },
|
||||
},
|
||||
});
|
||||
const { headers, user } = await signInWithTestUser();
|
||||
|
||||
const result = await client.linkSocial(
|
||||
{
|
||||
provider: "google",
|
||||
idToken: { token: testIdToken },
|
||||
},
|
||||
{ headers },
|
||||
);
|
||||
|
||||
expect(result.error).toBeNull();
|
||||
expect(result.data).toMatchObject({
|
||||
status: true,
|
||||
redirect: false,
|
||||
});
|
||||
|
||||
const context = await auth.$context;
|
||||
const accounts = await context.internalAdapter.findAccounts(user.id);
|
||||
expect(accounts.some((account) => account.providerId === "google")).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("should redirect to proxy url with profile data (passthrough)", async () => {
|
||||
const { client } = await getTestInstance({
|
||||
plugins: [
|
||||
|
||||
Reference in New Issue
Block a user