diff --git a/.cspell/third-party.txt b/.cspell/third-party.txt index 499238a34c..50f31cd047 100644 --- a/.cspell/third-party.txt +++ b/.cspell/third-party.txt @@ -39,3 +39,4 @@ authjs vite electronjs wagmi +tldts diff --git a/packages/sso/package.json b/packages/sso/package.json index 46fe0cf55c..80a0675813 100644 --- a/packages/sso/package.json +++ b/packages/sso/package.json @@ -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": { diff --git a/packages/sso/src/domain-verification.test.ts b/packages/sso/src/domain-verification.test.ts index 2db7be95ea..118a21c1df 100644 --- a/packages/sso/src/domain-verification.test.ts +++ b/packages/sso/src/domain-verification.test.ts @@ -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(); diff --git a/packages/sso/src/routes/domain-verification.ts b/packages/sso/src/routes/domain-verification.ts index 1b62b5b98a..64e5d12332 100644 --- a/packages/sso/src/routes/domain-verification.ts +++ b/packages/sso/src/routes/domain-verification.ts @@ -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) { diff --git a/packages/sso/src/utils.test.ts b/packages/sso/src/utils.test.ts index 32a56b68db..fec825824a 100644 --- a/packages/sso/src/utils.test.ts +++ b/packages/sso/src/utils.test.ts @@ -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(); + }); +}); diff --git a/packages/sso/src/utils.ts b/packages/sso/src/utils.ts index 679772e74a..a85ca1e841 100644 --- a/packages/sso/src/utils.ts +++ b/packages/sso/src/utils.ts @@ -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 "****"; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 15ce191c8c..ef4b07aef7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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