fix(sso): require DNS proof for every domain listed on a provider (#10227)

Co-authored-by: Gustavo Valverde <g.valverde02@gmail.com>
This commit is contained in:
Bereket Engida
2026-06-25 19:49:15 -07:00
committed by GitHub
parent 1a8b7ccc83
commit fcabaaffcb
9 changed files with 365 additions and 48 deletions

View File

@@ -0,0 +1,5 @@
---
"@better-auth/sso": patch
---
SSO domain verification now requires proof for every domain a provider lists. When a provider's `domain` has multiple comma-separated domains, each listed domain must publish the verification TXT record before the provider is marked verified. The verifier accepts TXT records that exactly match the raw verification token, matching the documented setup flow, or the existing `identifier=value` format.

View File

@@ -192,7 +192,7 @@ This allows most endpoint-related fields in `oidcConfig` to be **optional** —
*/
issuer: string = "https://your-org.okta.com"
/**
* Email domain for this provider
* Bare email domain, or comma-separated bare email domains, for this provider
*/
domain: string = "yourcompany.com"
/**
@@ -1346,7 +1346,7 @@ See the [Schema](#if-you-have-enabled-domain-verification) section to add the fi
### Verify your domain
When domain verification is enabled, every new SSO provider will be untrusted at first.
This means that new sign-ups or sign-ins will be allowed until the domain ownership has been verified.
This means that new sign-ups or sign-ins will not be allowed until domain ownership has been verified.
To verify your ownership over a domain, follow these steps:
@@ -1366,6 +1366,8 @@ To verify your ownership over a domain, follow these steps:
* **Host:** `_better-auth-token-{your-provider-id}` (**Note:** An underscore is automatically prepended to follow DNS infrastructure subdomain conventions. The `better-auth-token` part can be customized through the `domainVerification.tokenPrefix` option)
* **Value:** The verification token you were given.
If the SSO provider lists multiple comma-separated domains, add this `TXT` record under every listed domain. For example, a provider with `domain: "company.com,subsidiary.com"` must publish the record for both `company.com` and `subsidiary.com`.
**Save the record and wait for it to propagate.** This can take up to 48 hours, but it's usually much faster.
</Step>
@@ -1628,7 +1630,7 @@ export const ssoOptionsType = {
type: "object",
properties: {
domain: {
description: "The domain to match for this default provider.",
description: "The bare email domain, or comma-separated bare email domains, to match for this default provider.",
type: "string",
required: true,
},

View File

@@ -151,6 +151,7 @@ describe("Domain verification", async () => {
afterEach(() => {
vi.useRealTimers();
dnsMock.resolveTxt.mockReset();
});
describe("POST /sso/request-domain-verification", () => {
@@ -435,7 +436,36 @@ describe("Domain verification", async () => {
expect(response.status).toBe(502);
expect(await response.json()).toEqual({
message: "Unable to verify domain ownership. Try again later",
message:
"Unable to verify domain ownership for hello.com. Try again later",
code: "DOMAIN_VERIFICATION_FAILED",
});
});
it("should return bad gateway when the TXT record only contains the verification token as a substring", async () => {
const { auth, getAuthHeaders, registerSSOProvider } = createTestAuth();
const headers = await getAuthHeaders(testUser);
const provider = await registerSSOProvider(headers);
dnsMock.resolveTxt.mockResolvedValue([
[`prefix-${provider.domainVerificationToken}-suffix`],
[
`_better-auth-token-saml-provider-1=${provider.domainVerificationToken}-suffix`,
],
]);
const response = await auth.api.verifyDomain({
body: {
providerId: provider.providerId,
},
headers,
asResponse: true,
});
expect(response.status).toBe(502);
expect(await response.json()).toEqual({
message:
"Unable to verify domain ownership for hello.com. Try again later",
code: "DOMAIN_VERIFICATION_FAILED",
});
});
@@ -510,7 +540,8 @@ describe("Domain verification", async () => {
"v=spf1 ip4:50.242.118.232/29 include:_spf.google.com include:mail.zendesk.com ~all",
],
[
`_better-auth-token-saml-provider-1=${provider.domainVerificationToken}`,
"_better-auth-token-saml-provider-1=",
provider.domainVerificationToken,
],
]);
@@ -675,6 +706,108 @@ describe("Domain verification", async () => {
code: "DOMAIN_VERIFIED",
});
});
it("does not verify a URL-like multi-domain provider unless every later-accepted domain is owned", async () => {
const { auth, getAuthHeaders } = createTestAuth();
const headers = await getAuthHeaders(testUser);
await auth.api.registerSSOProvider({
body: {
providerId: "multi-domain-provider",
issuer: "https://idp.example.com",
// The URL-like first entry exercises the shared normalization used
// by both verification and later domain matching.
domain: "https://attacker.com/path,victim.com",
samlConfig: {
entryPoint: "http://idp.com:",
cert: "the-cert",
callbackUrl: "http://hello.com:8081/api/sso/saml2/callback",
spMetadata: {},
},
},
headers,
});
const requestResponse = await auth.api.requestDomainVerification({
body: { providerId: "multi-domain-provider" },
headers,
asResponse: true,
});
const { domainVerificationToken } = await requestResponse.json();
// Only attacker.com publishes the verifying record; victim.com does not.
dnsMock.resolveTxt.mockImplementation(async (name: string) => {
if (name === "_better-auth-token-multi-domain-provider.attacker.com") {
return [
[
`_better-auth-token-multi-domain-provider=${domainVerificationToken}`,
],
];
}
return [];
});
const verifyResponse = await auth.api.verifyDomain({
body: { providerId: "multi-domain-provider" },
headers,
asResponse: true,
});
// victim.com ownership was never proven, so the provider must not verify.
expect(verifyResponse.status).toBe(502);
expect(await verifyResponse.json()).toEqual({
message:
"Unable to verify domain ownership for victim.com. Try again later",
code: "DOMAIN_VERIFICATION_FAILED",
});
expect(dnsMock.resolveTxt).toHaveBeenCalledWith(
"_better-auth-token-multi-domain-provider.victim.com",
);
});
it("verifies a multi-domain provider when every listed domain is owned", async () => {
const { auth, getAuthHeaders } = createTestAuth();
const headers = await getAuthHeaders(testUser);
await auth.api.registerSSOProvider({
body: {
providerId: "owned-multi-domain",
issuer: "https://idp.company.example",
domain: "company.com,subsidiary.com",
samlConfig: {
entryPoint: "http://idp.com:",
cert: "the-cert",
callbackUrl: "http://hello.com:8081/api/sso/saml2/callback",
spMetadata: {},
},
},
headers,
});
const requestResponse = await auth.api.requestDomainVerification({
body: { providerId: "owned-multi-domain" },
headers,
asResponse: true,
});
const { domainVerificationToken } = await requestResponse.json();
// Both listed domains publish the verifying record.
dnsMock.resolveTxt.mockResolvedValue([[domainVerificationToken]]);
const verifyResponse = await auth.api.verifyDomain({
body: { providerId: "owned-multi-domain" },
headers,
asResponse: true,
});
expect(verifyResponse.status).toBe(204);
expect(dnsMock.resolveTxt).toHaveBeenCalledWith(
"_better-auth-token-owned-multi-domain.company.com",
);
expect(dnsMock.resolveTxt).toHaveBeenCalledWith(
"_better-auth-token-owned-multi-domain.subsidiary.com",
);
});
});
/**

View File

@@ -146,6 +146,59 @@ describe("assignOrganizationByDomain", () => {
expect(members[0]?.role).toBe("member");
});
it("should assign user when a verified provider's normalized domain set includes the email domain", async () => {
const { data, createContext } = createTestContext();
const org = createOrg();
data.organization.push(org);
data.ssoProvider.push(
createProvider({
domain: "https://attacker.com/path,victim.com",
domainVerified: true,
organizationId: org.id,
}),
);
const user = createUser({ email: "alice@victim.com" });
data.user.push(user);
const ctx = (await createContext()) as GenericEndpointContext;
await assignOrganizationByDomain(ctx, {
user,
domainVerification: { enabled: true },
});
const members = data.member.filter((m) => m.userId === user.id);
expect(members).toHaveLength(1);
expect(members[0]?.organizationId).toBe(org.id);
});
it("should NOT assign user when the email domain is malformed", async () => {
const { data, createContext } = createTestContext();
const org = createOrg();
data.organization.push(org);
data.ssoProvider.push(
createProvider({
domain: "victim.com",
domainVerified: true,
organizationId: org.id,
}),
);
const user = createUser({ email: "alice@https://victim.com/path" });
data.user.push(user);
const ctx = (await createContext()) as GenericEndpointContext;
await assignOrganizationByDomain(ctx, {
user,
domainVerification: { enabled: true },
});
const members = data.member.filter((m) => m.userId === user.id);
expect(members).toHaveLength(0);
});
it("should NOT assign user when email domain does not match any provider", async () => {
const { data, createContext } = createTestContext();

View File

@@ -1245,6 +1245,22 @@ describe("OIDC SSO with defaultSSO array", async () => {
"http://localhost:8080/.well-known/openid-configuration",
},
},
{
domain:
"https://default-oidc-normalized.com/path,default-oidc-secondary.com",
providerId: "default-oidc-provider-normalized",
oidcConfig: {
issuer: "http://localhost:8080",
clientId: "normalized-client",
clientSecret: "normalized-secret",
pkce: false,
authorizationEndpoint: "http://localhost:8080/authorize",
tokenEndpoint: "http://localhost:8080/token",
jwksEndpoint: "http://localhost:8080/jwks",
discoveryEndpoint:
"http://localhost:8080/.well-known/openid-configuration",
},
},
],
}),
organization(),
@@ -1274,10 +1290,17 @@ describe("OIDC SSO with defaultSSO array", async () => {
const tokenHandler = (token: any) => {
const isExplicit = token.payload.aud === "explicit-client";
currentEmail = isExplicit
? "default-sso-user@default-oidc-explicit.com"
: "default-sso-user@default-oidc.com";
currentSub = isExplicit ? "explicit-sso-sub" : "default-sso-sub";
const isNormalized = token.payload.aud === "normalized-client";
currentEmail = isNormalized
? "default-sso-user@default-oidc-secondary.com"
: isExplicit
? "default-sso-user@default-oidc-explicit.com"
: "default-sso-user@default-oidc.com";
currentSub = isNormalized
? "normalized-sso-sub"
: isExplicit
? "explicit-sso-sub"
: "default-sso-sub";
token.payload.email = currentEmail;
token.payload.email_verified = true;
token.payload.name = "Default SSO User";
@@ -1372,6 +1395,36 @@ describe("OIDC SSO with defaultSSO array", async () => {
expect(callbackURL).toContain("/dashboard");
});
it("should sign in via defaultSSO OIDC using normalized domain matching", async () => {
const headers = new Headers();
const res = await authClient.signIn.sso({
email: "someone@default-oidc-secondary.com",
callbackURL: "/dashboard",
fetchOptions: {
throw: true,
onSuccess: cookieSetter(headers),
},
});
expect(res.url).toContain("http://localhost:8080/authorize");
expect(res.url).toContain(
"redirect_uri=http%3A%2F%2Flocalhost%3A3000%2Fapi%2Fauth%2Fsso%2Fcallback%2Fdefault-oidc-provider-normalized",
);
const { callbackURL, headers: sessionHeaders } = await simulateOAuthFlow(
res.url,
headers,
);
expect(callbackURL).toContain("/dashboard");
const session = await authClient.getSession({
fetchOptions: { headers: sessionHeaders },
});
expect(session.data?.user.email).toBe(
"default-sso-user@default-oidc-secondary.com",
);
});
it("should sign in via defaultSSO OIDC with all endpoints explicit (no discovery needed)", async () => {
const headers = new Headers();
const res = await authClient.signIn.sso({

View File

@@ -6,7 +6,7 @@ import {
import { generateRandomString } from "better-auth/crypto";
import * as z from "zod";
import type { SSOOptions, SSOProvider } from "../types";
import { getHostnameFromDomain } from "../utils";
import { parseProviderDomains } from "../utils";
import { checkProviderAccess } from "./providers";
const DNS_LABEL_MAX_LENGTH = 63;
@@ -159,7 +159,6 @@ export const verifyDomain = (options: SSOOptions) => {
});
}
let records: string[] = [];
let dns: typeof import("node:dns/promises");
try {
@@ -175,36 +174,46 @@ export const verifyDomain = (options: SSOOptions) => {
});
}
const hostname = getHostnameFromDomain(provider.domain);
if (!hostname) {
const domains = parseProviderDomains(provider.domain);
if (!domains) {
throw new APIError("BAD_REQUEST", {
message: "Invalid domain",
code: "INVALID_DOMAIN",
});
}
try {
const dnsRecords = await dns.resolveTxt(`${identifier}.${hostname}`);
records = dnsRecords.flat();
} catch (error) {
ctx.context.logger.warn(
"DNS resolution failure while validating domain ownership",
error,
);
}
for (const domain of domains) {
let records: string[] = [];
try {
const dnsRecords = await dns.resolveTxt(`${identifier}.${domain}`);
records = dnsRecords.map((record) => record.join(""));
} catch (error) {
ctx.context.logger.warn(
`DNS resolution failure while validating domain ownership for ${domain}`,
error,
);
}
const record = records.find((record) =>
record.includes(
`${activeVerification.identifier}=${activeVerification.value}`,
),
);
if (!record) {
throw new APIError("BAD_GATEWAY", {
message: "Unable to verify domain ownership. Try again later",
code: "DOMAIN_VERIFICATION_FAILED",
const verificationValue = activeVerification.value;
const verificationRecord = `${activeVerification.identifier}=${verificationValue}`;
const record = records.find((record) => {
const normalizedRecord = record.trim();
return (
normalizedRecord === verificationRecord ||
normalizedRecord === verificationValue
);
});
if (!record) {
throw new APIError("BAD_GATEWAY", {
message: `Unable to verify domain ownership for ${domain}. Try again later`,
code: "DOMAIN_VERIFICATION_FAILED",
});
}
}
// FIXME(next): this remains a provider-level proof bit. When the next
// schema can change, store verification per normalized domain or force
// previously verified multi-domain providers through re-verification.
await ctx.context.adapter.update<SSOProvider<SSOOptions>>({
model: "ssoProvider",
where: [{ field: "providerId", value: provider.providerId }],

View File

@@ -1099,7 +1099,8 @@ export const signInSSO = (options?: SSOOptions) => {
(defaultProvider) => defaultProvider.providerId === providerId,
)
: options.defaultSSO.find(
(defaultProvider) => defaultProvider.domain === domain,
(defaultProvider) =>
domain && domainMatches(domain, defaultProvider.domain),
);
if (matchingDefault) {

View File

@@ -1,6 +1,7 @@
import { describe, expect, it } from "vitest";
import {
getHostnameFromDomain,
domainMatches,
parseProviderDomains,
parseProviderEmailVerified,
validateEmailDomain,
} from "./utils";
@@ -37,6 +38,21 @@ describe("parseProviderEmailVerified", () => {
});
});
describe("parseProviderDomains", () => {
it("normalizes comma-separated provider domains", () => {
expect(
parseProviderDomains(
"https://company.com/path, subsidiary.com, COMPANY.com",
),
).toEqual(["company.com", "subsidiary.com"]);
});
it("returns null when no domain can be parsed", () => {
expect(parseProviderDomains(", ,")).toBeNull();
expect(parseProviderDomains("")).toBeNull();
});
});
/**
* @see https://github.com/better-auth/better-auth/issues/7324
*/
@@ -120,6 +136,19 @@ describe("validateEmailDomain", () => {
expect(validateEmailDomain("user@company.com", domains)).toBe(true);
expect(validateEmailDomain("USER@SUBSIDIARY.COM", domains)).toBe(true);
});
it("should use the same normalized domains as ownership verification", () => {
const domains = "https://attacker.com/path,victim.com";
expect(domainMatches("attacker.com", domains)).toBe(true);
expect(validateEmailDomain("user@attacker.com", domains)).toBe(true);
expect(validateEmailDomain("user@victim.com", domains)).toBe(true);
});
it("should not normalize malformed email domains", () => {
expect(
validateEmailDomain("user@https://victim.com/path", "victim.com"),
).toBe(false);
});
});
describe("edge cases", () => {
@@ -144,30 +173,34 @@ describe("validateEmailDomain", () => {
/**
* @see https://github.com/better-auth/better-auth/issues/8361
*/
describe("getHostnameFromDomain", () => {
describe("parseProviderDomains hostname normalization", () => {
it("should extract hostname from a bare domain", () => {
expect(getHostnameFromDomain("github.com")).toBe("github.com");
expect(parseProviderDomains("github.com")).toEqual(["github.com"]);
});
it("should extract hostname from a full URL", () => {
expect(getHostnameFromDomain("https://github.com")).toBe("github.com");
expect(parseProviderDomains("https://github.com")).toEqual(["github.com"]);
});
it("should extract hostname from a URL with port", () => {
expect(getHostnameFromDomain("https://github.com:8081")).toBe("github.com");
expect(parseProviderDomains("https://github.com:8081")).toEqual([
"github.com",
]);
});
it("should extract hostname from a subdomain", () => {
expect(getHostnameFromDomain("auth.github.com")).toBe("auth.github.com");
expect(parseProviderDomains("auth.github.com")).toEqual([
"auth.github.com",
]);
});
it("should extract hostname from a URL with path", () => {
expect(getHostnameFromDomain("https://github.com/path/to/resource")).toBe(
"github.com",
expect(parseProviderDomains("https://github.com/path/to/resource")).toEqual(
["github.com"],
);
});
it("should return null for an empty string", () => {
expect(getHostnameFromDomain("")).toBeNull();
expect(parseProviderDomains("")).toBeNull();
});
});

View File

@@ -36,12 +36,14 @@ export function safeJsonParse<T>(
* Checks if a domain matches any domain in a comma-separated list.
*/
export const domainMatches = (searchDomain: string, domainList: string) => {
const search = searchDomain.toLowerCase();
const domains = domainList
.split(",")
.map((d) => d.trim().toLowerCase())
.filter(Boolean);
return domains.some((d) => search === d || search.endsWith(`.${d}`));
const search = searchDomain.trim().toLowerCase();
const domains = parseProviderDomains(domainList);
if (!search || !domains) {
return false;
}
return domains.some(
(domain) => search === domain || search.endsWith(`.${domain}`),
);
};
/**
@@ -103,10 +105,36 @@ export function normalizePem(key: string | undefined): string | undefined {
.trim()}\n`;
}
export function getHostnameFromDomain(domain: string): string | null {
function getHostnameFromDomain(domain: string): string | null {
return getHostname(domain) || null;
}
/**
* Normalize a provider `domain` value to the email domains it authorizes.
*
* TODO(next): replace the serialized provider.domain string with a canonical
* domains array and reject URL/path-shaped values at register/update once main
* and next provider schemas are reconciled.
*/
export function parseProviderDomains(domain: string): string[] | null {
const entries = domain
.split(",")
.map((entry) => entry.trim())
.filter(Boolean);
if (entries.length === 0) {
return null;
}
const domains = new Set<string>();
for (const entry of entries) {
const parsedDomain = getHostnameFromDomain(entry)?.toLowerCase();
if (!parsedDomain) {
return null;
}
domains.add(parsedDomain);
}
return [...domains];
}
export function maskClientId(clientId: string): string {
if (clientId.length <= 4) {
return "****";