test(demo): validate migrated 1.6 SCIM and SSO workflows (#10986)

This commit is contained in:
Gustavo Valverde
2026-08-28 22:34:57 +00:00
committed by GitHub
parent 2430e3ff1f
commit 5af501fd3b
6 changed files with 685 additions and 30 deletions
+3
View File
@@ -19,6 +19,9 @@ SCIM_DEMO_OIDC_CLIENT_SECRET=
SCIM_DEMO_OIDC_SIGNING_PRIVATE_KEY=
# Optional local SQLite database path used by the demo and its end-to-end tests.
DEMO_SQLITE_PATH=
# Keep this empty only for v1.7 issuer compatibility mode. Use provider-id for
# a new database or one migrated from v1.6 with provider-id identity.
DEMO_ACCOUNT_IDENTITY_STRATEGY=
TURSO_DATABASE_URL=
TURSO_AUTH_TOKEN=
APPLE_CLIENT_ID=
+12
View File
@@ -38,6 +38,15 @@ import { getSCIMDemoOIDCProvider } from "./scim-demo-oidc.ts";
const DEMO_SQLITE_CONTENTION_TIMEOUT_MS = 5_000;
const demoAccountIdentityStrategy = (() => {
const strategy = process.env.DEMO_ACCOUNT_IDENTITY_STRATEGY;
if (!strategy) return undefined;
if (strategy === "issuer" || strategy === "provider-id") return strategy;
throw new Error(
'DEMO_ACCOUNT_IDENTITY_STRATEGY must be either "issuer" or "provider-id"',
);
})();
const database = (() => {
if (process.env.DEMO_SQLITE_PATH) {
return {
@@ -129,6 +138,9 @@ const authOptions = {
},
},
account: {
...(demoAccountIdentityStrategy && {
identityStrategy: demoAccountIdentityStrategy,
}),
accountLinking: {
trustedProviders: [
"email-password",
+332 -30
View File
@@ -7,11 +7,15 @@ import { join, resolve } from "node:path";
import { DatabaseSync } from "node:sqlite";
import type { APIRequestContext, BrowserContext, Page } from "@playwright/test";
import { expect, test } from "@playwright/test";
import type { MigratedPublished16Database } from "../published-1-6-migration";
import { prepareMigratedPublished16Database } from "../published-1-6-migration";
const USER_SCHEMA = "urn:ietf:params:scim:schemas:core:2.0:User";
const GROUP_SCHEMA = "urn:ietf:params:scim:schemas:core:2.0:Group";
const PATCH_SCHEMA = "urn:ietf:params:scim:api:messages:2.0:PatchOp";
const demoDirectory = resolve(process.cwd(), "../../demo/nextjs");
const demoAuthSecret =
"better-auth-scim-demo-e2e-secret-at-least-thirty-two-characters";
interface NextDemoRuntime {
baseURL: string;
@@ -21,6 +25,11 @@ interface NextDemoRuntime {
temporaryDirectory: string;
}
interface NextDemoRuntimeOptions {
accountIdentityStrategy?: "issuer" | "provider-id";
prepareDatabase?: (databasePath: string) => Promise<void>;
}
async function getAvailablePort(): Promise<number> {
const server = createServer();
const port = await new Promise<number>((resolvePort, reject) => {
@@ -48,18 +57,26 @@ async function stopProcess(
await terminate(child.pid);
}
async function startNextDemoRuntime(): Promise<NextDemoRuntime> {
async function startNextDemoRuntime(
options: NextDemoRuntimeOptions = {},
): Promise<NextDemoRuntime> {
const temporaryDirectory = await mkdtemp(
join(tmpdir(), "better-auth-scim-managed-demo-"),
);
const port = await getAvailablePort();
const baseURL = `http://127.0.0.1:${port}`;
const databasePath = join(temporaryDirectory, "demo.sqlite");
try {
await options.prepareDatabase?.(databasePath);
} catch (error) {
await rm(temporaryDirectory, { force: true, recursive: true });
throw error;
}
const environment: NodeJS.ProcessEnv = {
...process.env,
BETTER_AUTH_SECRET:
"better-auth-scim-demo-e2e-secret-at-least-thirty-two-characters",
BETTER_AUTH_SECRET: demoAuthSecret,
BETTER_AUTH_URL: baseURL,
DEMO_ACCOUNT_IDENTITY_STRATEGY: options.accountIdentityStrategy,
DEMO_SQLITE_PATH: databasePath,
NO_COLOR: "1",
SCIM_DEMO_CREDENTIAL_PEPPER:
@@ -167,6 +184,23 @@ async function signUp(
expect(response.ok(), await response.text()).toBe(true);
}
async function signIn(
context: BrowserContext,
baseURL: string,
email: string,
): Promise<void> {
const response = await context.request.post(
`${baseURL}/api/auth/sign-in/email`,
{
data: {
email,
password: "correct-horse-battery-staple",
},
},
);
expect(response.ok(), await response.text()).toBe(true);
}
async function createOrganization(
request: APIRequestContext,
baseURL: string,
@@ -199,6 +233,32 @@ function managementURL(baseURL: string, organizationId: string): string {
return `${baseURL}/api/scim-demo/organizations/${organizationId}/connections`;
}
async function createEmployeeLink(
request: APIRequestContext,
baseURL: string,
organizationId: string,
scimUserId: string,
): Promise<string> {
const response = await request.post(
`${managementURL(baseURL, organizationId)}/employee-links`,
{
headers: { origin: baseURL },
data: { organizationId, scimUserId },
},
);
expect(response.status(), await response.text()).toBe(201);
const body: unknown = await response.json();
if (
typeof body !== "object" ||
body === null ||
!("url" in body) ||
typeof body.url !== "string"
) {
throw new Error("Employee link response did not return a URL");
}
return body.url;
}
async function readIssuedCredential(response: {
json(): Promise<unknown>;
}): Promise<{ id: string; token: string }> {
@@ -328,11 +388,20 @@ async function completeEmployeeSignIn(page: Page): Promise<void> {
page.getByRole("heading", { name: "Sign in with Acme Identity" }),
).toBeVisible();
await page.getByRole("button", { name: "Continue as Maya" }).click();
await expect(
page.getByRole("heading", { name: "You’re signed in" }),
).toBeVisible();
try {
await expect(
page.getByRole("heading", { name: "You’re signed in" }),
).toBeVisible();
} catch (error) {
throw new Error(
`Employee SSO did not complete at ${page.url()}:\n${await page.locator("body").innerText()}`,
{ cause: error },
);
}
}
test.describe.configure({ mode: "serial" });
test.describe("Next.js managed SCIM catalog demo", () => {
test.describe.configure({ mode: "serial" });
test.setTimeout(120_000);
@@ -898,33 +967,16 @@ test.describe("Next.js managed SCIM catalog demo", () => {
database.close();
}
const employeeLinkResponse = await page.request.post(
`${managementURL(runtime.baseURL, organizationId)}/employee-links`,
{
headers: { origin: runtime.baseURL },
data: {
organizationId,
scimUserId: maya.id,
},
},
const employeeLinkURL = await createEmployeeLink(
page.request,
runtime.baseURL,
organizationId,
String(maya.id),
);
expect(
employeeLinkResponse.status(),
await employeeLinkResponse.text(),
).toBe(201);
const employeeLinkBody: unknown = await employeeLinkResponse.json();
if (
typeof employeeLinkBody !== "object" ||
employeeLinkBody === null ||
!("url" in employeeLinkBody) ||
typeof employeeLinkBody.url !== "string"
) {
throw new Error("Employee link response did not return a URL");
}
const employeeContext = await browser.newContext();
try {
const employeePage = await employeeContext.newPage();
await employeePage.goto(employeeLinkBody.url);
await employeePage.goto(employeeLinkURL);
await completeEmployeeSignIn(employeePage);
} finally {
await employeeContext.close();
@@ -936,13 +988,14 @@ test.describe("Next.js managed SCIM catalog demo", () => {
try {
const accounts = accountDatabase
.prepare(
`SELECT "providerId", "accountId", "userId"
`SELECT "issuer", "providerId", "accountId", "userId"
FROM "account"
WHERE "providerId" = 'scim-demo-sso'`,
)
.all();
expect(accounts).toEqual([
{
issuer: `${runtime.baseURL}/api/scim-demo/idp`,
providerId: "scim-demo-sso",
accountId: maya.externalId,
userId: maya.userId,
@@ -962,3 +1015,252 @@ test.describe("Next.js managed SCIM catalog demo", () => {
}
});
});
test.describe("Next.js SCIM and SSO demo on a migrated published 1.6 database", () => {
test.describe.configure({ mode: "serial" });
test.setTimeout(180_000);
let migration: MigratedPublished16Database | undefined;
let runtime: NextDemoRuntime | undefined;
test.beforeAll(async () => {
runtime = await startNextDemoRuntime({
accountIdentityStrategy: "provider-id",
prepareDatabase: async (databasePath) => {
migration = await prepareMigratedPublished16Database(
databasePath,
demoAuthSecret,
);
},
});
});
test.afterAll(async () => {
await stopProcess(runtime?.process);
if (runtime) {
await rm(runtime.temporaryDirectory, {
recursive: true,
force: true,
});
}
});
test("runs the real SCIM lifecycle and repeated OIDC SSO sign-in without re-keying migrated identities", async ({
browser,
page,
}) => {
if (!runtime || !migration) {
throw new Error("The migrated Next.js demo runtime was not prepared");
}
await signIn(
page.context(),
runtime.baseURL,
"administrator@migration.example.com",
);
const organizationId = await createOrganization(
page.request,
runtime.baseURL,
"Migrated SCIM Demo",
);
const provisioningDomainId = `scim-demo-org:${organizationId}`;
await page.goto(`${runtime.baseURL}/dashboard/scim`);
const createResponsePromise = page.waitForResponse(
(response) =>
response.url() === managementURL(runtime.baseURL, organizationId) &&
response.request().method() === "POST",
);
await page.getByRole("button", { name: "Create SCIM connection" }).click();
const createResponse = await createResponsePromise;
expect(createResponse.status()).toBe(201);
const originalCredential = await readIssuedCredential(createResponse);
await page.getByRole("button", { name: "Run local recipe" }).click();
await expect(
page.getByRole("heading", { name: "Recipe complete" }),
).toBeVisible();
await expect(
page
.getByTestId("entra-local-recipe-steps")
.locator('[data-step-status="passed"]'),
).toHaveCount(10);
await expect(
page.getByRole("heading", { name: "Users (2)" }),
).toBeVisible();
await expect(
page.getByRole("heading", { name: "Groups (1)" }),
).toBeVisible();
const database = new DatabaseSync(runtime.databasePath, {
readOnly: true,
});
let demoEmployee: {
externalId: string;
id: string;
userId: string;
};
try {
const employee = database
.prepare(
`SELECT "id", "userId", "externalId"
FROM "scimUser"
WHERE "provisioningDomainId" = ?
AND "serializedAttributes" LIKE '%Finance Director%'`,
)
.get(provisioningDomainId);
if (
typeof employee?.id !== "string" ||
typeof employee.userId !== "string" ||
typeof employee.externalId !== "string"
) {
throw new Error("The migrated demo did not provision its SSO employee");
}
demoEmployee = {
externalId: employee.externalId,
id: employee.id,
userId: employee.userId,
};
expect(
database
.prepare(
`SELECT COUNT(*) AS "count"
FROM "account"
WHERE "providerId" = 'scim-demo-sso'`,
)
.get(),
).toEqual({ count: 0 });
} finally {
database.close();
}
for (let signInAttempt = 0; signInAttempt < 2; signInAttempt += 1) {
const employeeLink = await createEmployeeLink(
page.request,
runtime.baseURL,
organizationId,
demoEmployee.id,
);
const employeeContext = await browser.newContext();
try {
const employeePage = await employeeContext.newPage();
await employeePage.goto(employeeLink);
await completeEmployeeSignIn(employeePage);
} finally {
await employeeContext.close();
}
}
const migratedDatabase = new DatabaseSync(runtime.databasePath, {
readOnly: true,
});
try {
const accounts = migratedDatabase
.prepare(
`SELECT "issuer", "providerId", "accountId", "userId"
FROM "account"
WHERE "providerId" IN (
'credential',
'scim-demo-sso',
'workforce-scim',
'workforce-sso'
)
ORDER BY "providerId", "accountId"`,
)
.all();
expect(accounts).toEqual([
expect.objectContaining({
issuer: "local:credential",
providerId: "credential",
userId: migration.source.administratorUserId,
}),
{
issuer: "local:oauth:scim-demo-sso",
providerId: "scim-demo-sso",
accountId: demoEmployee.externalId,
userId: demoEmployee.userId,
},
{
issuer: "local:oauth:workforce-sso",
providerId: "workforce-sso",
accountId: migration.source.directorySubject,
userId: migration.verified.ssoUserId,
},
]);
expect(
migratedDatabase
.prepare(
`SELECT "userId"
FROM "scimUser"
WHERE "id" = ?`,
)
.get(migration.verified.reprovisionedSCIMUserId),
).toEqual({ userId: migration.verified.scimUserId });
} finally {
migratedDatabase.close();
}
const rotateResponsePromise = page.waitForResponse(
(response) =>
response.url() ===
`${managementURL(runtime.baseURL, organizationId)}/rotate` &&
response.request().method() === "POST",
);
await page.getByRole("button", { name: "Rotate credential" }).click();
const rotateResponse = await rotateResponsePromise;
expect(rotateResponse.status()).toBe(201);
const rotatedCredential = await readIssuedCredential(rotateResponse);
expect(rotatedCredential.token).not.toBe(originalCredential.token);
const revokeResponsePromise = page.waitForResponse(
(response) =>
response.url() ===
`${managementURL(
runtime.baseURL,
organizationId,
)}/credentials/${originalCredential.id}/revoke` &&
response.request().method() === "POST",
);
await page
.getByRole("button", {
name: `Revoke credential ${originalCredential.id}`,
})
.click();
expect((await revokeResponsePromise).status()).toBe(200);
expect(
(
await scimRequest(
page.request,
runtime.baseURL,
originalCredential.token,
{ method: "GET", path: "/Users" },
)
).status(),
).toBe(401);
expect(
(
await scimRequest(
page.request,
runtime.baseURL,
rotatedCredential.token,
{ method: "GET", path: "/Users" },
)
).status(),
).toBe(200);
const decommissionResponsePromise = page.waitForResponse(
(response) =>
response.url() ===
`${managementURL(runtime.baseURL, organizationId)}/decommission` &&
response.request().method() === "POST",
);
await page.getByRole("button", { name: "Decommission connection" }).click();
await page
.getByRole("button", { name: "Decommission permanently" })
.click();
expect((await decommissionResponsePromise).status()).toBe(200);
await expect(
page.getByText("Connection permanently decommissioned"),
).toBeVisible();
});
});
@@ -0,0 +1,334 @@
import { spawn } from "node:child_process";
import { existsSync } from "node:fs";
import { readFile, writeFile } from "node:fs/promises";
import { dirname, join, resolve } from "node:path";
interface CommandResult {
exitCode: number | null;
stderr: string;
stdout: string;
}
interface PublishedAccount {
accountId: string;
id: string;
providerId: string;
userId: string;
}
interface Published16Result {
accounts: PublishedAccount[];
administratorUserId: string;
clientId: string;
clientSecret: string;
directorySubject: string;
scimAccountId: string;
tableCounts: Record<string, number>;
}
interface GuidedVerificationResult {
credentialUserId: string;
reprovisionedSCIMUserId: string;
scimUserId: string;
ssoUserId: string;
}
export interface MigratedPublished16Database {
source: Published16Result;
verified: GuidedVerificationResult;
}
const workspaceRootDirectory = resolve(process.cwd(), "../..");
const migrationFixtureDirectory = join(
workspaceRootDirectory,
"e2e/adapter/test/kysely-adapter",
);
const published16Directory = join(
migrationFixtureDirectory,
"published-1-6-app",
);
const published16Seed = join(published16Directory, "seed.mjs");
const guidedConfig = join(migrationFixtureDirectory, "guided-auth.mjs");
const guidedVerifier = join(
migrationFixtureDirectory,
"verify-guided-migration.mjs",
);
const cliEntry = join(workspaceRootDirectory, "packages/cli/dist/index.mjs");
const commandTimeoutMs = 60_000;
function redactCommandOutput(output: string): string {
return output
.replace(
/^PUBLISHED_FIXTURE_RESULT=.*$/gm,
"PUBLISHED_FIXTURE_RESULT=[redacted]",
)
.replace(
/^GUIDED_MIGRATION_RESULT=.*$/gm,
"GUIDED_MIGRATION_RESULT=[redacted]",
);
}
function runNode(
cwd: string,
args: string[],
environment: NodeJS.ProcessEnv = {},
): Promise<CommandResult> {
return new Promise((resolveCommand, rejectCommand) => {
const child = spawn(process.execPath, args, {
cwd,
env: { ...process.env, ...environment, NO_COLOR: "1" },
stdio: ["ignore", "pipe", "pipe"],
});
let stdout = "";
let stderr = "";
let settled = false;
let timeout: ReturnType<typeof setTimeout>;
const settle = (callback: () => void) => {
if (settled) return;
settled = true;
clearTimeout(timeout);
callback();
};
timeout = setTimeout(() => {
child.kill();
settle(() =>
rejectCommand(
new Error(
`Subprocess timed out after ${commandTimeoutMs}ms:\n${redactCommandOutput(stdout)}\n${redactCommandOutput(stderr)}`,
),
),
);
}, commandTimeoutMs);
child.stdout.setEncoding("utf8");
child.stderr.setEncoding("utf8");
child.stdout.on("data", (chunk: string) => {
stdout += chunk;
});
child.stderr.on("data", (chunk: string) => {
stderr += chunk;
});
child.once("error", (error) => {
settle(() => rejectCommand(error));
});
child.once("close", (exitCode) => {
settle(() => resolveCommand({ exitCode, stderr, stdout }));
});
});
}
function requireSuccessfulCommand(
result: CommandResult,
context: string,
): void {
if (result.exitCode === 0) return;
throw new Error(
`${context} failed with exit code ${result.exitCode}:\n${redactCommandOutput(result.stdout)}\n${redactCommandOutput(result.stderr)}`,
);
}
function readSentinelResult<Result>(stdout: string, sentinel: string): Result {
const line = stdout
.split("\n")
.find((candidate) => candidate.startsWith(`${sentinel}=`));
if (!line) {
throw new Error(
`Missing ${sentinel} in subprocess output:\n${redactCommandOutput(stdout)}`,
);
}
return JSON.parse(line.slice(sentinel.length + 1)) as Result;
}
async function requireFixtureFiles(): Promise<void> {
for (const requiredFile of [
cliEntry,
published16Seed,
guidedConfig,
guidedVerifier,
]) {
if (!existsSync(requiredFile)) {
throw new Error(`Missing published migration fixture: ${requiredFile}`);
}
}
const packageMetadata = JSON.parse(
await readFile(
join(published16Directory, "node_modules/better-auth/package.json"),
"utf8",
),
) as { version?: string };
if (packageMetadata.version !== "1.6.30") {
throw new Error(
`Expected the published 1.6 fixture to use better-auth 1.6.30, received ${packageMetadata.version ?? "unknown"}`,
);
}
}
export async function prepareMigratedPublished16Database(
databasePath: string,
authSecret: string,
): Promise<MigratedPublished16Database> {
await requireFixtureFiles();
const environment = {
BETTER_AUTH_MIGRATION_DATABASE: databasePath,
BETTER_AUTH_SECRET: authSecret,
};
const seed = await runNode(
published16Directory,
[published16Seed, databasePath],
environment,
);
requireSuccessfulCommand(seed, "Published Better Auth 1.6 seed");
const source = readSentinelResult<Published16Result>(
seed.stdout,
"PUBLISHED_FIXTURE_RESULT",
);
const sourceProviders = source.accounts.map(({ providerId }) => providerId);
if (
source.tableCounts.account !== 3 ||
!sourceProviders.includes("credential") ||
!sourceProviders.includes("workforce-scim") ||
!sourceProviders.includes("workforce-sso")
) {
throw new Error(
`Published Better Auth 1.6 did not create the required populated workflows: accountCount=${source.tableCounts.account ?? "unknown"}, providers=${sourceProviders.join(",")}`,
);
}
const decisionsFile = join(
dirname(databasePath),
"better-auth-migration.json",
);
await writeFile(
decisionsFile,
`${JSON.stringify(
{
formatVersion: 1,
migration: "1.6-to-1.7",
oauth: {
clientSecrets: { source: "plain", target: "hashed" },
consents: "migrate",
},
scim: { retireAccountIds: [source.scimAccountId] },
},
null,
2,
)}\n`,
"utf8",
);
const plan = await runNode(
migrationFixtureDirectory,
[
cliEntry,
"migrate",
"plan",
decisionsFile,
"--config",
guidedConfig,
"--json",
],
environment,
);
requireSuccessfulCommand(plan, "Guided Better Auth 1.7 migration plan");
const parsedPlan = JSON.parse(plan.stdout) as {
accountIdentity?: { selectedStrategy?: string };
blockers?: unknown[];
status?: string;
};
if (
parsedPlan.status !== "ready" ||
parsedPlan.blockers?.length !== 0 ||
parsedPlan.accountIdentity?.selectedStrategy !== "provider-id"
) {
throw new Error(
`Guided migration did not produce a ready provider-id plan: ${plan.stdout}`,
);
}
const apply = await runNode(
migrationFixtureDirectory,
[
cliEntry,
"migrate",
"apply",
decisionsFile,
"--config",
guidedConfig,
"--yes",
"--json",
],
environment,
);
requireSuccessfulCommand(apply, "Guided Better Auth 1.7 migration apply");
const parsedApply = JSON.parse(apply.stdout) as { status?: string };
if (parsedApply.status !== "applied") {
throw new Error(`Guided migration was not applied: ${apply.stdout}`);
}
const repeatedPlan = await runNode(
migrationFixtureDirectory,
[
cliEntry,
"migrate",
"plan",
decisionsFile,
"--config",
guidedConfig,
"--json",
],
environment,
);
requireSuccessfulCommand(
repeatedPlan,
"Repeated Better Auth 1.7 migration plan",
);
const parsedRepeatedPlan = JSON.parse(repeatedPlan.stdout) as {
blockers?: unknown[];
changes?: {
addColumns?: unknown[];
addIndexes?: unknown[];
createTables?: unknown[];
};
releaseMigration?: unknown;
status?: string;
};
if (
parsedRepeatedPlan.status !== "up-to-date" ||
parsedRepeatedPlan.blockers?.length !== 0 ||
parsedRepeatedPlan.changes?.addColumns?.length !== 0 ||
parsedRepeatedPlan.changes?.addIndexes?.length !== 0 ||
parsedRepeatedPlan.changes?.createTables?.length !== 0 ||
parsedRepeatedPlan.releaseMigration !== undefined
) {
throw new Error(
`Guided migration did not become up-to-date after apply: ${repeatedPlan.stdout}`,
);
}
const provisionedSourceAccount = source.accounts.find(
({ providerId }) => providerId === "workforce-scim",
);
if (!provisionedSourceAccount) {
throw new Error("Published Better Auth 1.6 did not create a SCIM account");
}
const verification = await runNode(
migrationFixtureDirectory,
[guidedVerifier, databasePath],
{
...environment,
BETTER_AUTH_MIGRATION_CLIENT_ID: source.clientId,
BETTER_AUTH_MIGRATION_CLIENT_SECRET: source.clientSecret,
BETTER_AUTH_MIGRATION_SCIM_USER_ID: provisionedSourceAccount.userId,
},
);
requireSuccessfulCommand(
verification,
"Migrated Better Auth 1.7 workflow verification",
);
const verified = readSentinelResult<GuidedVerificationResult>(
verification.stdout,
"GUIDED_MIGRATION_RESULT",
);
return { source, verified };
}
+1
View File
@@ -14,6 +14,7 @@
"@better-auth/scim": "workspace:*",
"@better-auth/sso": "workspace:*",
"@better-auth/stripe": "workspace:*",
"auth": "workspace:*",
"better-auth": "workspace:*"
},
"devDependencies": {
+3
View File
@@ -802,6 +802,9 @@ importers:
'@better-auth/stripe':
specifier: workspace:*
version: link:../../packages/stripe
auth:
specifier: workspace:*
version: link:../../packages/cli
better-auth:
specifier: workspace:*
version: link:../../packages/better-auth