Files
openwork/evals/specs/sso-domain-verification.e2e.test.ts

192 lines
8.1 KiB
TypeScript

import { expect } from "vitest";
import { denFetch, evalIn, provisionOrg, waitFor } from "@openwork/behaviors";
import { navigate } from "@openwork/cdp";
import { chrome } from "@openwork/hosts";
import { startMockIdpLab } from "@openwork/labs";
import { localMysqlIsRunning, server, test } from "@openwork/testkit";
const localPlacement = process.env.OPENWORK_EVAL_DAYTONA !== "1" && !process.env.OPENWORK_EVAL_DEN_API_URL?.trim();
const mysqlOpen = await localMysqlIsRunning();
const title = !localPlacement
? "SSO domain verification skipped — needs local placement without OPENWORK_EVAL_DEN_API_URL"
: !mysqlOpen
? "SSO domain verification skipped — needs MySQL on 127.0.0.1:3306"
: "an unverified SSO connection stays pending and gives the owner complete DNS instructions";
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function readString(value: unknown, key: string): string {
return isRecord(value) && typeof value[key] === "string" ? value[key] : "";
}
test.skipIf(!localPlacement || !mysqlOpen)(title, async ({ evidence, place }) => {
const ssoDomain = `pending-sso-${Date.now()}.test`;
await using idp = await startMockIdpLab({
issuer: "http://0.0.0.0:19191",
domain: ssoDomain,
});
await using den = await server({ place, trustedOrigins: [new URL(idp.issuer).origin] });
const org = await provisionOrg(den.ref, { members: [] });
const signedIn = await denFetch(den.ref, "/api/auth/sign-in/email", {
method: "POST",
body: JSON.stringify({ email: org.admin.email, password: org.admin.password }),
});
const sessionCookie = signedIn.response.headers.get("set-cookie")?.split(";")[0]?.trim() ?? "";
expect(sessionCookie).toBeTruthy();
const organizations = await denFetch(den.ref, "/v1/me/orgs", {
headers: { authorization: `Bearer ${org.admin.token}` },
});
const organization = isRecord(organizations.body)
&& Array.isArray(organizations.body.orgs)
&& isRecord(organizations.body.orgs[0])
? organizations.body.orgs[0]
: null;
const organizationSlug = readString(organization, "slug");
expect(organizationSlug).toBeTruthy();
const registration = idp.registration();
const registered = await denFetch(den.ref, "/v1/sso/oidc", {
method: "POST",
headers: {
authorization: `Bearer ${org.admin.token}`,
cookie: sessionCookie,
"x-openwork-org-id": org.orgId,
},
body: JSON.stringify({
issuer: registration.issuer,
domain: registration.domain,
clientId: registration.clientId,
clientSecret: registration.clientSecret,
scopes: registration.scopes,
skipDiscovery: registration.skipDiscovery,
authorizationEndpoint: registration.authorizationEndpoint,
tokenEndpoint: registration.tokenEndpoint,
jwksEndpoint: registration.jwksEndpoint,
userInfoEndpoint: registration.userInfoEndpoint,
tokenEndpointAuthentication: registration.tokenEndpointAuthentication,
}),
});
expect(registered.response.ok, registered.text).toBe(true);
const registeredConnection = isRecord(registered.body) && isRecord(registered.body.connection)
? registered.body.connection
: null;
const providerId = readString(registeredConnection, "providerId");
const domainVerificationToken = readString(registered.body, "domainVerificationToken");
const expectedHost = `_better-auth-token-${providerId}`;
const expectedDnsName = `${expectedHost}.${ssoDomain}`;
expect(domainVerificationToken).toBeTruthy();
expect(registeredConnection?.domainVerified).toBe(false);
expect(readString(registeredConnection, "status")).toBe("pending_verification");
expect(readString(registeredConnection, "domainVerificationHost")).toBe(expectedHost);
expect(readString(registeredConnection, "domainVerificationDnsName")).toBe(expectedDnsName);
const loginOptions = await denFetch(
den.ref,
`/v1/auth/login-options?email=${encodeURIComponent(`new-user@${ssoDomain}`)}`,
);
expect(loginOptions.response.ok, loginOptions.text).toBe(true);
expect(readString(loginOptions.body, "nextStep")).not.toBe("sso");
const directSso = await denFetch(den.ref, "/api/auth/sign-in/sso", {
method: "POST",
body: JSON.stringify({
organizationSlug,
callbackURL: `${den.ref.webUrl}/dashboard`,
}),
});
expect(directSso.response.ok).toBe(false);
expect(directSso.text).toMatch(/has not been verified/i);
evidence.recordAssertionEvidence(
"Unverified SSO is configured but inactive",
`status=${readString(registeredConnection, "status")}; login next step=${readString(loginOptions.body, "nextStep")}; direct SSO HTTP ${directSso.response.status}`,
readString(registeredConnection, "status") === "pending_verification"
&& readString(loginOptions.body, "nextStep") !== "sso"
&& !directSso.response.ok,
);
await using browser = await chrome({
name: "sso-domain-verification",
startUrl: den.ref.webUrl,
headless: true,
host: place.host(),
});
await waitFor(browser, `location.href.startsWith(${JSON.stringify(den.ref.webUrl)}) && document.readyState === "complete"`, {
timeoutMs: 60_000,
label: "Den Web before owner auth handoff",
});
const cookieSeparator = sessionCookie.indexOf("=");
const browserSessionCookie = await browser.client.send("Network.setCookie", {
name: sessionCookie.slice(0, cookieSeparator),
value: sessionCookie.slice(cookieSeparator + 1),
url: den.ref.webUrl,
httpOnly: true,
});
expect(browserSessionCookie.success).toBe(true);
const tokenStored = await evalIn(browser, `(() => {
localStorage.setItem("openwork:web:auth-token", ${JSON.stringify(org.admin.token)});
return localStorage.getItem("openwork:web:auth-token") === ${JSON.stringify(org.admin.token)};
})()`);
expect(tokenStored).toBe(true);
await navigate(browser.client, `${den.ref.webUrl}/dashboard/sso`);
await waitFor(browser, `Boolean(document.querySelector('[data-testid="sso-domain-verification"]'))`, {
timeoutMs: 60_000,
label: "pending SSO verification card",
});
const requested = await evalIn(browser, `(() => {
const button = [...document.querySelectorAll("button")]
.find((candidate) => /request token/i.test(candidate.textContent ?? ""));
button?.click();
return Boolean(button);
})()`);
expect(requested).toBe(true);
await waitFor(browser, `document.body.innerText.includes(${JSON.stringify(expectedDnsName)}) && document.body.innerText.includes(${JSON.stringify(domainVerificationToken)})`, {
timeoutMs: 30_000,
label: "complete DNS verification record with token value",
});
const ui: unknown = JSON.parse(String(await evalIn(browser, `(() => {
const verification = document.querySelector('[data-testid="sso-domain-verification"]');
const setup = document.querySelector('[data-testid="sso-provider-setup"]');
const text = verification?.textContent?.replace(/\\s+/g, " ").trim() ?? "";
return JSON.stringify({
appearsBeforeProviderSetup: Boolean(verification && setup && (verification.compareDocumentPosition(setup) & Node.DOCUMENT_POSITION_FOLLOWING)),
hasTxtType: /TXT/.test(text),
hasHost: text.includes(${JSON.stringify(expectedHost)}),
hasFullName: text.includes(${JSON.stringify(expectedDnsName)}),
hasToken: text.includes(${JSON.stringify(domainVerificationToken)}),
saysPending: /pending verification/i.test(text),
saysRemovable: /remove.*TXT record/i.test(text),
hasSkip: /skip verification/i.test(text),
text,
});
})()`)));
expect(ui).toMatchObject({
appearsBeforeProviderSetup: true,
hasTxtType: true,
hasHost: true,
hasFullName: true,
hasToken: true,
saysPending: true,
saysRemovable: true,
hasSkip: false,
});
evidence.recordAssertionEvidence(
"The owner sees domain verification first with complete, one-time DNS instructions",
readString(ui, "text"),
isRecord(ui)
&& ui.appearsBeforeProviderSetup === true
&& ui.hasTxtType === true
&& ui.hasHost === true
&& ui.hasFullName === true
&& ui.hasToken === true
&& ui.saysPending === true
&& ui.saysRemovable === true
&& ui.hasSkip === false,
);
});