fix(sso): handle bare domains in domain verification (#8369)

This commit is contained in:
Alex Yang
2026-03-06 10:11:06 -08:00
parent f46045ecd9
commit fb7a0b7458
7 changed files with 111 additions and 2 deletions
+1
View File
@@ -39,3 +39,4 @@ authjs
vite
electronjs
wagmi
tldts
+1
View File
@@ -69,6 +69,7 @@
"fast-xml-parser": "^5.4.1",
"jose": "^6.1.3",
"samlify": "^2.10.2",
"tldts": "^6.1.0",
"zod": "^4.3.6"
},
"devDependencies": {
@@ -530,6 +530,53 @@ describe("Domain verification", async () => {
);
});
/**
* @see https://github.com/better-auth/better-auth/issues/8361
*/
it("should verify a provider domain ownership with a bare domain", async () => {
const { auth, getAuthHeaders } = createTestAuth();
const headers = await getAuthHeaders(testUser);
await auth.api.registerSSOProvider({
body: {
providerId: "bare-domain-provider",
issuer: "http://hello.com:8081",
domain: "hello.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: "bare-domain-provider" },
headers,
asResponse: true,
});
expect(requestResponse.status).toBe(201);
const { domainVerificationToken } = await requestResponse.json();
dnsMock.resolveTxt.mockResolvedValue([
[`_better-auth-token-bare-domain-provider=${domainVerificationToken}`],
]);
const verifyResponse = await auth.api.verifyDomain({
body: { providerId: "bare-domain-provider" },
headers,
asResponse: true,
});
expect(verifyResponse.status).toBe(204);
expect(dnsMock.resolveTxt).toHaveBeenCalledWith(
"_better-auth-token-bare-domain-provider.hello.com",
);
});
it("should return bad request when provider ID exceeds DNS label limit", async () => {
const longProviderId = "a".repeat(50);
const { auth, getAuthHeaders } = createTestAuth();
@@ -6,6 +6,7 @@ import {
import { generateRandomString } from "better-auth/crypto";
import * as z from "zod/v4";
import type { SSOOptions, SSOProvider } from "../types";
import { getHostnameFromDomain } from "../utils";
const DNS_LABEL_MAX_LENGTH = 63;
const DEFAULT_TOKEN_PREFIX = "better-auth-token";
@@ -241,8 +242,15 @@ export const verifyDomain = (options: SSOOptions) => {
});
}
const hostname = getHostnameFromDomain(provider.domain);
if (!hostname) {
throw new APIError("BAD_REQUEST", {
message: "Invalid domain",
code: "INVALID_DOMAIN",
});
}
try {
const hostname = new URL(provider.domain).hostname;
const dnsRecords = await dns.resolveTxt(`${identifier}.${hostname}`);
records = dnsRecords.flat();
} catch (error) {
+32 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { validateEmailDomain } from "./utils";
import { getHostnameFromDomain, validateEmailDomain } from "./utils";
/**
* @see https://github.com/better-auth/better-auth/issues/7324
@@ -104,3 +104,34 @@ describe("validateEmailDomain", () => {
});
});
});
/**
* @see https://github.com/better-auth/better-auth/issues/8361
*/
describe("getHostnameFromDomain", () => {
it("should extract hostname from a bare domain", () => {
expect(getHostnameFromDomain("github.com")).toBe("github.com");
});
it("should extract hostname from a full URL", () => {
expect(getHostnameFromDomain("https://github.com")).toBe("github.com");
});
it("should extract hostname from a URL with port", () => {
expect(getHostnameFromDomain("https://github.com:8081")).toBe("github.com");
});
it("should extract hostname from a subdomain", () => {
expect(getHostnameFromDomain("auth.github.com")).toBe("auth.github.com");
});
it("should extract hostname from a URL with path", () => {
expect(getHostnameFromDomain("https://github.com/path/to/resource")).toBe(
"github.com",
);
});
it("should return null for an empty string", () => {
expect(getHostnameFromDomain("")).toBeNull();
});
});
+5
View File
@@ -1,4 +1,5 @@
import { X509Certificate } from "node:crypto";
import { getHostname } from "tldts";
/**
* Safely parses a value that might be a JSON string or already a parsed object.
@@ -73,6 +74,10 @@ export function parseCertificate(certPem: string) {
};
}
export function getHostnameFromDomain(domain: string): string | null {
return getHostname(domain) || null;
}
export function maskClientId(clientId: string): string {
if (clientId.length <= 4) {
return "****";
+16
View File
@@ -1593,6 +1593,9 @@ importers:
samlify:
specifier: ^2.10.2
version: 2.10.2
tldts:
specifier: ^6.1.0
version: 6.1.86
zod:
specifier: ^4.3.6
version: 4.3.6
@@ -15611,9 +15614,16 @@ packages:
resolution: {integrity: sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==}
engines: {node: '>=14.0.0'}
tldts-core@6.1.86:
resolution: {integrity: sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==}
tldts-core@7.0.23:
resolution: {integrity: sha512-0g9vrtDQLrNIiCj22HSe9d4mLVG3g5ph5DZ8zCKBr4OtrspmNB6ss7hVyzArAeE88ceZocIEGkyW1Ime7fxPtQ==}
tldts@6.1.86:
resolution: {integrity: sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==}
hasBin: true
tldts@7.0.23:
resolution: {integrity: sha512-ASdhgQIBSay0R/eXggAkQ53G4nTJqTXqC2kbaBbdDwM7SkjyZyO0OaaN1/FH7U/yCeqOHDwFO5j8+Os/IS1dXw==}
hasBin: true
@@ -34137,8 +34147,14 @@ snapshots:
tinyrainbow@3.0.3: {}
tldts-core@6.1.86: {}
tldts-core@7.0.23: {}
tldts@6.1.86:
dependencies:
tldts-core: 6.1.86
tldts@7.0.23:
dependencies:
tldts-core: 7.0.23