mirror of
https://github.com/better-auth/better-auth.git
synced 2026-05-30 02:46:44 -05:00
feat(sso): use domain verified flag to trust providers automatically
This commit is contained in:
committed by
github-actions[bot]
parent
14d5ef9ab6
commit
312fc0248a
@@ -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.
|
||||
|
||||
<Tabs items={["client", "server"]}>
|
||||
<Tab value="client">
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -7,20 +7,17 @@ import { setTokenUtil } from "./utils";
|
||||
|
||||
export async function handleOAuthUserInfo(
|
||||
c: GenericEndpointContext,
|
||||
{
|
||||
userInfo,
|
||||
account,
|
||||
callbackURL,
|
||||
disableSignUp,
|
||||
overrideUserInfo,
|
||||
}: {
|
||||
opts: {
|
||||
userInfo: Omit<User, "createdAt" | "updatedAt">;
|
||||
account: Omit<Account, "id" | "userId" | "createdAt" | "updatedAt">;
|
||||
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
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user