feat(sso): use domain verified flag to trust providers automatically

This commit is contained in:
Paola Estefanía de Campos
2025-12-08 18:12:14 -03:00
committed by github-actions[bot]
parent 14d5ef9ab6
commit 312fc0248a
5 changed files with 341 additions and 11 deletions

View File

@@ -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">

View File

@@ -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: {

View File

@@ -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

View File

@@ -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");
});
});

View File

@@ -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(