diff --git a/docs/content/docs/plugins/sso.mdx b/docs/content/docs/plugins/sso.mdx index c67b79432f..038a5f9e6b 100644 --- a/docs/content/docs/plugins/sso.mdx +++ b/docs/content/docs/plugins/sso.mdx @@ -717,7 +717,9 @@ mapping: { ## Domain verification Domain verification allows your application to automatically trust a new SSO provider -by automatically validating ownership via the associated domain: +by automatically validating ownership via the associated domain. + +When a provider's domain is verified, it is also trusted for **automatic account linking**. This means that if a user signs in with an SSO provider (OIDC or SAML) and an existing account with the same email exists, the accounts will be linked automatically — as long as the user's email domain matches the provider's verified domain. diff --git a/packages/better-auth/src/oauth2/link-account.test.ts b/packages/better-auth/src/oauth2/link-account.test.ts index 1302662fdf..bb21d37cd2 100644 --- a/packages/better-auth/src/oauth2/link-account.test.ts +++ b/packages/better-auth/src/oauth2/link-account.test.ts @@ -231,6 +231,167 @@ describe("oauth2 - email verification on link", async () => { }); }); +describe("oauth2 - account linking without trustedProviders", async () => { + const { auth, client, cookieSetter } = await getTestInstance({ + socialProviders: { + google: { + clientId: "test", + clientSecret: "test", + enabled: true, + }, + }, + emailAndPassword: { + enabled: true, + }, + account: { + accountLinking: { + enabled: true, + trustedProviders: [], + }, + }, + }); + + const ctx = await auth.$context; + + it("should deny account linking when provider is not trusted and email is not verified", async () => { + const testEmail = "untrusted@example.com"; + + await ctx.adapter.create({ + model: "user", + data: { + id: "existing-user-id", + email: testEmail, + name: "Existing User", + emailVerified: true, + createdAt: new Date(), + updatedAt: new Date(), + }, + }); + + server.use( + http.post("https://oauth2.googleapis.com/token", async () => { + const profile = { + email: testEmail, + email_verified: false, + name: "Test User", + sub: "google_untrusted_123", + iat: 1234567890, + exp: 1234567890, + aud: "test", + iss: "test", + }; + const idToken = await signJWT(profile, DEFAULT_SECRET); + return HttpResponse.json({ + access_token: "test_access_token", + refresh_token: "test_refresh_token", + id_token: idToken, + }); + }), + ); + + const oAuthHeaders = new Headers(); + const signInRes = await client.signIn.social({ + provider: "google", + callbackURL: "/", + fetchOptions: { + onSuccess: cookieSetter(oAuthHeaders), + }, + }); + + const state = new URL(signInRes.data!.url!).searchParams.get("state") || ""; + let redirectLocation = ""; + await client.$fetch("/callback/google", { + query: { state, code: "test_code" }, + method: "GET", + headers: oAuthHeaders, + onError(context) { + redirectLocation = context.response.headers.get("location") || ""; + }, + }); + + expect(redirectLocation).toContain("error=account_not_linked"); + + const accounts = await ctx.adapter.findMany<{ providerId: string }>({ + model: "account", + where: [{ field: "userId", value: "existing-user-id" }], + }); + const googleAccount = accounts.find((a) => a.providerId === "google"); + expect(googleAccount).toBeUndefined(); + }); + + it("should allow account linking when email is verified by provider", async () => { + const testEmail = "verified-provider@example.com"; + + await ctx.adapter.create({ + model: "user", + data: { + id: "existing-user-verified", + email: testEmail, + name: "Existing User", + emailVerified: false, + createdAt: new Date(), + updatedAt: new Date(), + }, + }); + + server.use( + http.post("https://oauth2.googleapis.com/token", async () => { + const profile = { + email: testEmail, + email_verified: true, + name: "Test User", + sub: "google_verified_456", + iat: 1234567890, + exp: 1234567890, + aud: "test", + iss: "test", + }; + const idToken = await signJWT(profile, DEFAULT_SECRET); + return HttpResponse.json({ + access_token: "test_access_token", + refresh_token: "test_refresh_token", + id_token: idToken, + }); + }), + ); + + const oAuthHeaders = new Headers(); + const signInRes = await client.signIn.social({ + provider: "google", + callbackURL: "/dashboard", + fetchOptions: { + onSuccess: cookieSetter(oAuthHeaders), + }, + }); + + const state = new URL(signInRes.data!.url!).searchParams.get("state") || ""; + let redirectLocation = ""; + await client.$fetch("/callback/google", { + query: { state, code: "test_code" }, + method: "GET", + headers: oAuthHeaders, + onError(context) { + redirectLocation = context.response.headers.get("location") || ""; + }, + }); + + expect(redirectLocation).not.toContain("error=account_not_linked"); + + const user = await ctx.adapter.findOne<{ id: string }>({ + model: "user", + where: [{ field: "email", value: testEmail }], + }); + expect(user).toBeTruthy(); + + const accounts = await ctx.adapter.findMany<{ providerId: string }>({ + model: "account", + where: [{ field: "userId", value: user!.id }], + }); + const googleAccount = accounts.find((a) => a.providerId === "google"); + expect(googleAccount).toBeTruthy(); + }); +}); + describe("oauth2 - override user info on sign-in", async () => { const { auth, client, cookieSetter } = await getTestInstance({ socialProviders: { diff --git a/packages/better-auth/src/oauth2/link-account.ts b/packages/better-auth/src/oauth2/link-account.ts index 71734a47c4..072cd89240 100644 --- a/packages/better-auth/src/oauth2/link-account.ts +++ b/packages/better-auth/src/oauth2/link-account.ts @@ -7,20 +7,17 @@ import { setTokenUtil } from "./utils"; export async function handleOAuthUserInfo( c: GenericEndpointContext, - { - userInfo, - account, - callbackURL, - disableSignUp, - overrideUserInfo, - }: { + opts: { userInfo: Omit; account: Omit; callbackURL?: string | undefined; disableSignUp?: boolean | undefined; overrideUserInfo?: boolean | undefined; + isTrustedProvider?: boolean | undefined; }, ) { + const { userInfo, account, callbackURL, disableSignUp, overrideUserInfo } = + opts; const dbUser = await c.context.internalAdapter .findOAuthUser( userInfo.email.toLowerCase(), @@ -48,9 +45,9 @@ export async function handleOAuthUserInfo( if (!hasBeenLinked) { const trustedProviders = c.context.options.account?.accountLinking?.trustedProviders; - const isTrustedProvider = trustedProviders?.includes( - account.providerId as "apple", - ); + const isTrustedProvider = + opts.isTrustedProvider || + trustedProviders?.includes(account.providerId as "apple"); if ( (!isTrustedProvider && !userInfo.emailVerified) || c.context.options.account?.accountLinking?.enabled === false diff --git a/packages/sso/src/oidc.test.ts b/packages/sso/src/oidc.test.ts index d32dbf387c..e037b84d20 100644 --- a/packages/sso/src/oidc.test.ts +++ b/packages/sso/src/oidc.test.ts @@ -571,3 +571,167 @@ describe("provisioning", async (ctx) => { expect(res.url).toContain("http://localhost:8080/authorize"); }); }); + +describe("OIDC account linking with domainVerified", async () => { + const { auth, signInWithTestUser, customFetchImpl, cookieSetter } = + await getTestInstance({ + account: { + accountLinking: { + enabled: true, + trustedProviders: [], + }, + }, + plugins: [ + sso({ + domainVerification: { + enabled: true, + }, + }), + ], + }); + + const authClient = createAuthClient({ + plugins: [ssoClient()], + baseURL: "http://localhost:3000", + fetchOptions: { + customFetchImpl, + }, + }); + + beforeAll(async () => { + await server.issuer.keys.generate("RS256"); + await server.start(8080, "localhost"); + }); + + afterAll(async () => { + await server.stop().catch(() => {}); + }); + + async function simulateOAuthFlow(authUrl: string, headers: Headers) { + let location: string | null = null; + await betterFetch(authUrl, { + method: "GET", + redirect: "manual", + onError(context) { + location = context.response.headers.get("location"); + }, + }); + + if (!location) throw new Error("No redirect location found"); + + let callbackURL = ""; + const newHeaders = new Headers(); + await betterFetch(location, { + method: "GET", + customFetchImpl, + headers, + onError(context) { + callbackURL = context.response.headers.get("location") || ""; + cookieSetter(newHeaders)(context); + }, + }); + + return { callbackURL, headers: newHeaders }; + } + + it("should allow account linking when domain is verified and email domain matches", async () => { + const testEmail = "linking-test@verified-oidc.com"; + const testDomain = "verified-oidc.com"; + + server.service.on("beforeTokenSigning", (token) => { + token.payload.email = testEmail; + token.payload.email_verified = false; + token.payload.name = "Domain Verified User"; + token.payload.sub = "oidc-domain-verified-user"; + }); + + const { headers } = await signInWithTestUser(); + + const provider = await auth.api.registerSSOProvider({ + body: { + providerId: "domain-verified-oidc", + issuer: server.issuer.url!, + domain: testDomain, + oidcConfig: { + clientId: "test", + clientSecret: "test", + authorizationEndpoint: `${server.issuer.url}/authorize`, + tokenEndpoint: `${server.issuer.url}/token`, + jwksEndpoint: `${server.issuer.url}/jwks`, + discoveryEndpoint: `${server.issuer.url}/.well-known/openid-configuration`, + mapping: { + id: "sub", + email: "email", + emailVerified: "email_verified", + name: "name", + }, + }, + }, + headers, + }); + + expect(provider.domainVerified).toBe(false); + + const ctx = await auth.$context; + await ctx.adapter.update({ + model: "ssoProvider", + where: [{ field: "providerId", value: provider.providerId }], + update: { + domainVerified: true, + }, + }); + + const updatedProvider = await ctx.adapter.findOne<{ + domainVerified: boolean; + domain: string; + }>({ + model: "ssoProvider", + where: [{ field: "providerId", value: provider.providerId }], + }); + expect(updatedProvider?.domainVerified).toBe(true); + + await ctx.adapter.create({ + model: "user", + data: { + id: "existing-oidc-domain-user", + email: testEmail, + name: "Existing User", + emailVerified: true, + createdAt: new Date(), + updatedAt: new Date(), + }, + forceAllowId: true, + }); + + const newHeaders = new Headers(); + const res = await authClient.signIn.sso({ + providerId: "domain-verified-oidc", + callbackURL: "/dashboard", + fetchOptions: { + throw: true, + onSuccess: cookieSetter(newHeaders), + }, + }); + + expect(res.url).toContain("http://localhost:8080/authorize"); + + const { callbackURL } = await simulateOAuthFlow(res.url, newHeaders); + + expect(callbackURL).toContain("/dashboard"); + expect(callbackURL).not.toContain("error"); + + const accounts = await ctx.adapter.findMany<{ + providerId: string; + accountId: string; + userId: string; + }>({ + model: "account", + where: [{ field: "userId", value: "existing-oidc-domain-user" }], + }); + const linkedAccount = accounts.find( + (a) => a.providerId === "domain-verified-oidc", + ); + expect(linkedAccount).toBeTruthy(); + expect(linkedAccount?.accountId).toBe("oidc-domain-verified-user"); + }); +}); diff --git a/packages/sso/src/routes/sso.ts b/packages/sso/src/routes/sso.ts index f6e998ff5c..07ecd554f3 100644 --- a/packages/sso/src/routes/sso.ts +++ b/packages/sso/src/routes/sso.ts @@ -1349,6 +1349,11 @@ export const callbackSSO = (options?: SSOOptions) => { }/error?error=invalid_provider&error_description=missing_user_info`, ); } + const isTrustedProvider = + "domainVerified" in provider && + (provider as { domainVerified?: boolean }).domainVerified === true && + validateEmailDomain(userInfo.email, provider.domain); + const linked = await handleOAuthUserInfo(ctx, { userInfo: { email: userInfo.email, @@ -1372,6 +1377,7 @@ export const callbackSSO = (options?: SSOOptions) => { callbackURL, disableSignUp: options?.disableImplicitSignUp && !requestSignUp, overrideUserInfo: config.overrideUserInfo, + isTrustedProvider, }); if (linked.error) { throw ctx.redirect(