import { expect } from "vitest"; import { denFetch, signIn } from "@openwork/behaviors"; import { eventually, inviteMember, needs, server, test, unmetNeeds } from "@openwork/testkit"; import type { TestNeeds } from "@openwork/testkit"; const requirements: TestNeeds = { optIn: ["OPENWORK_EVAL_E2E_TESTS"], }; const missingRequirements = unmetNeeds(requirements, process.env); const title = missingRequirements.length > 0 ? `Okta-shaped SCIM lifecycle skipped — needs: ${missingRequirements.join(", ")}` : "an Okta-shaped SCIM client provisions, renames, deprovisions, and reactivates a member without ever granting password access"; const SCIM_USER_SCHEMA = "urn:ietf:params:scim:schemas:core:2.0:User"; const SCIM_PATCH_SCHEMA = "urn:ietf:params:scim:api:messages:2.0:PatchOp"; function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } function stringField(value: unknown, key: string): string | null { if (!isRecord(value)) return null; const field = value[key]; return typeof field === "string" ? field : null; } function numberField(value: unknown, key: string): number | null { if (!isRecord(value)) return null; const field = value[key]; return typeof field === "number" ? field : null; } function recordsField(value: unknown, key: string): Record[] { if (!isRecord(value) || !Array.isArray(value[key])) return []; return value[key].filter(isRecord); } async function scimFetch( apiUrl: string, path: string, bearer: string, init: RequestInit = {}, ): Promise<{ response: Response; body: unknown; text: string }> { const headers = new Headers(init.headers); headers.set("authorization", `Bearer ${bearer}`); headers.set("accept", "application/scim+json, application/json"); headers.set("content-type", "application/scim+json"); const response = await fetch(`${apiUrl.replace(/\/+$/, "")}${path}`, { ...init, headers, signal: init.signal ?? AbortSignal.timeout(30_000), }); const text = await response.text(); let body: unknown = text; try { body = text.trim() ? JSON.parse(text) : null; } catch { body = text; } return { response, body, text }; } function orgHasJoinedMember(body: unknown, email: string): boolean { return recordsField(body, "members").some((member) => { const user = member.user; return typeof member.userId === "string" && isRecord(user) && user.email === email; }); } function formattedName(body: unknown): string | null { if (!isRecord(body)) return null; return stringField(body.name, "formatted"); } test(title, { timeout: 1_800_000 }, async ({ evidence, place }) => { needs(requirements); const runId = `${Date.now().toString(36)}${process.pid.toString(36)}`; const orgName = `Okta SCIM Lifecycle ${runId}`; const managedDomain = "okta-scim.test"; const managedEmail = `avery.${runId}@${managedDomain}`; const controlPassword = "OpenWorkEval123!"; await using den = await server({ place, org: { name: orgName, admin: { name: "SCIM Admin" }, }, }); const control = await inviteMember(den, "control", { email: `control.${runId}@openwork.test`, name: "Control Member", password: controlPassword, }); const controlSession = await signIn(den.ref, { email: control.email, password: controlPassword }); const organizations = await denFetch(den.ref, "/v1/me/orgs", { headers: { authorization: `Bearer ${den.admin.token}` }, }); const organization = recordsField(organizations.body, "orgs").find((entry) => entry.name === orgName); const organizationId = stringField(organization, "id"); if (!organizations.response.ok || !organizationId) { throw new Error(`Organization lookup failed: HTTP ${organizations.response.status} ${organizations.text.slice(0, 500)}`); } // The org-scoped token route requires a verified, enabled SSO connection. // Den's dev-loopback issuer path makes domain verification deterministic in // the eval while the Okta-shaped entry point, certificate, and ACS contract // remain real. const adminSignIn = await denFetch(den.ref, "/api/auth/sign-in/email", { method: "POST", body: JSON.stringify({ email: den.admin.email, password: den.admin.password }), }); const sessionCookie = adminSignIn.response.headers.get("set-cookie")?.split(";")[0]?.trim() ?? ""; if (!adminSignIn.response.ok || !sessionCookie) { throw new Error(`Admin cookie sign-in failed: HTTP ${adminSignIn.response.status} ${adminSignIn.text.slice(0, 500)}`); } const adminHeaders = { authorization: `Bearer ${den.admin.token}`, cookie: sessionCookie, "x-openwork-org-id": organizationId, }; const sso = await denFetch(den.ref, "/v1/sso/saml", { method: "POST", headers: adminHeaders, body: JSON.stringify({ issuer: `http://127.0.0.1/okta/exk-${runId}`, domain: managedDomain, entryPoint: `https://okta.example.test/app/openwork/exk-${runId}/sso/saml`, cert: "okta-test-signing-certificate", audience: den.ref.apiUrl, }), }); if (!sso.response.ok) { throw new Error(`SSO prerequisite failed: HTTP ${sso.response.status} ${sso.text.slice(0, 500)}`); } const ssoConnection = isRecord(sso.body) && isRecord(sso.body.connection) ? sso.body.connection : null; expect(ssoConnection?.domainVerified).toBe(true); expect(stringField(ssoConnection, "status")).toBe("enabled"); const acsUrl = stringField(ssoConnection, "acsUrl"); if (!acsUrl) { throw new Error(`SAML registration did not advertise an ACS URL: ${sso.text.slice(0, 500)}`); } const malformedSaml = await fetch(acsUrl, { method: "POST", headers: { "content-type": "application/x-www-form-urlencoded" }, body: new URLSearchParams({ SAMLResponse: "not-base64-xml" }), signal: AbortSignal.timeout(30_000), }); const malformedSamlBody: unknown = await malformedSaml.json(); expect(stringField(malformedSamlBody, "error")).toBe("invalid_encoding"); expect(stringField(malformedSamlBody, "error")).not.toBe("invalid_saml_configuration"); evidence.recordAssertionEvidence( "Okta SAML registration persists a usable ACS callback configuration", `POSTing a deliberately malformed assertion to the advertised ACS URL reached response-policy validation and returned invalid_encoding, not invalid_saml_configuration.`, malformedSaml.status === 400 && stringField(malformedSamlBody, "error") === "invalid_encoding", ); // ── Frame 1: Den issues the org's Okta bearer, never a raw BA token ────── const scimBasePath = "/api/auth/scim/v2"; const garbage = await scimFetch(den.ref.apiUrl, `${scimBasePath}/Users`, "nonsense"); expect(garbage.response.status).toBe(401); const tokenResult = await denFetch(den.ref, "/v1/scim/token", { method: "POST", headers: adminHeaders, }); const scimToken = stringField(tokenResult.body, "scimToken"); const advertisedBaseUrl = stringField(tokenResult.body, "baseUrl"); if (tokenResult.response.status !== 201 || !scimToken || !advertisedBaseUrl) { throw new Error(`SCIM token creation failed: HTTP ${tokenResult.response.status} ${tokenResult.text.slice(0, 500)}`); } expect(new URL(advertisedBaseUrl).pathname).toBe(scimBasePath); const emptyAfterGarbage = await scimFetch(den.ref.apiUrl, `${scimBasePath}/Users`, scimToken); expect(emptyAfterGarbage.response.status).toBe(200); expect( numberField(emptyAfterGarbage.body, "totalResults"), `list after garbage bearer: HTTP ${emptyAfterGarbage.response.status} ${emptyAfterGarbage.text.slice(0, 300)}`, ).toBe(0); evidence.recordAssertionEvidence( "Den issues an org-scoped SCIM bearer and rejects the garbage bearer without mutation", `POST /v1/scim/token advertised ${advertisedBaseUrl}; Bearer nonsense returned ${garbage.response.status}, and the new real bearer then listed totalResults=0.`, tokenResult.response.status === 201 && new URL(advertisedBaseUrl).pathname === scimBasePath && garbage.response.status === 401 && emptyAfterGarbage.response.status === 200 && numberField(emptyAfterGarbage.body, "totalResults") === 0, ); // ── Frame 2: Okta checks for an existing assignment before creating it ─── const lookup = await scimFetch( den.ref.apiUrl, `${scimBasePath}/Users?filter=${encodeURIComponent(`userName eq "${managedEmail}"`)}`, scimToken, ); expect(lookup.response.status).toBe(200); expect( numberField(lookup.body, "totalResults"), `pre-create lookup totalResults: HTTP ${lookup.response.status} ${lookup.text.slice(0, 300)}`, ).toBe(0); expect( recordsField(lookup.body, "Resources"), `pre-create lookup Resources: HTTP ${lookup.response.status} ${lookup.text.slice(0, 300)}`, ).toHaveLength(0); evidence.recordAssertionEvidence( "Okta's pre-create lookup finds no existing assignment", `GET Users?filter=userName eq ${managedEmail} returned 200 with totalResults=0 and no Resources, so the later create cannot be mistaken for an update.`, lookup.response.status === 200 && numberField(lookup.body, "totalResults") === 0 && recordsField(lookup.body, "Resources").length === 0, ); const oktaUser = { schemas: [SCIM_USER_SCHEMA], userName: managedEmail, name: { givenName: "Avery", familyName: "Morgan" }, emails: [{ primary: true, value: managedEmail, type: "work" }], externalId: `00u-${runId}`, active: true, displayName: "Avery Morgan", }; // ── Frame 3: Okta provisions membership, not a password credential ─────── const created = await scimFetch(den.ref.apiUrl, `${scimBasePath}/Users`, scimToken, { method: "POST", body: JSON.stringify(oktaUser), }); const scimUserId = stringField(created.body, "id"); if (created.response.status !== 201 || !scimUserId) { throw new Error(`SCIM user creation failed: HTTP ${created.response.status} ${created.text.slice(0, 500)}`); } // SCIM writes bypass the 60s organizationContext cache TTL. const orgAfterCreate = await eventually( () => denFetch(den.ref, "/v1/org", { headers: adminHeaders }), { within: 90_000, label: "SCIM-created member to appear in organization context", until: ({ response, body }) => response.ok && orgHasJoinedMember(body, managedEmail), }, ); const passwordAfterCreate = await denFetch(den.ref, "/api/auth/sign-in/email", { method: "POST", body: JSON.stringify({ email: managedEmail, password: "not-a-scim-password" }), }); const allAfterCreate = await scimFetch(den.ref.apiUrl, `${scimBasePath}/Users`, scimToken); expect(orgAfterCreate.response.ok).toBe(true); expect(orgHasJoinedMember(orgAfterCreate.body, managedEmail)).toBe(true); expect(passwordAfterCreate.response.ok).toBe(false); expect( numberField(allAfterCreate.body, "totalResults"), `list after create: HTTP ${allAfterCreate.response.status} ${allAfterCreate.text.slice(0, 300)}`, ).toBe(1); evidence.recordAssertionEvidence( "Okta creates only federated access", `After the zero-user baseline, the real token lists exactly one SCIM resource (${managedEmail}), /v1/org includes that member within the 60s org-context cache window, and password sign-in returned ${passwordAfterCreate.response.status}; the rejected garbage call therefore added no extra resource.`, numberField(allAfterCreate.body, "totalResults") === 1 && orgHasJoinedMember(orgAfterCreate.body, managedEmail) && !passwordAfterCreate.response.ok, ); // ── Frame 4: Okta's full profile replacement changes only that profile ─── const renamedUser = { ...oktaUser, name: { givenName: "Avery", familyName: "Nguyen" }, displayName: "Avery Nguyen", }; const renamed = await scimFetch(den.ref.apiUrl, `${scimBasePath}/Users/${encodeURIComponent(scimUserId)}`, scimToken, { method: "PUT", body: JSON.stringify(renamedUser), }); const renamedGet = await scimFetch(den.ref.apiUrl, `${scimBasePath}/Users/${encodeURIComponent(scimUserId)}`, scimToken); expect(renamed.response.status).toBe(200); expect(renamedGet.response.status).toBe(200); expect( formattedName(renamedGet.body), `renamed user formatted name: HTTP ${renamedGet.response.status} ${renamedGet.text.slice(0, 300)}`, ).toBe("Avery Nguyen"); expect( formattedName(renamedGet.body), `renamed user old formatted name: HTTP ${renamedGet.response.status} ${renamedGet.text.slice(0, 300)}`, ).not.toBe("Avery Morgan"); evidence.recordAssertionEvidence( "Okta's full replace updates the family name without retaining the old profile", `PUT and GET returned ${renamed.response.status}/${renamedGet.response.status}; GET formatted the name as ${formattedName(renamedGet.body)}, not Avery Morgan.`, renamed.response.status === 200 && renamedGet.response.status === 200 && formattedName(renamedGet.body) === "Avery Nguyen", ); // ── Frame 5: deprovisioning blocks resurrection and leaves others alone ── const deactivated = await scimFetch(den.ref.apiUrl, `${scimBasePath}/Users/${encodeURIComponent(scimUserId)}`, scimToken, { method: "PATCH", body: JSON.stringify({ schemas: [SCIM_PATCH_SCHEMA], Operations: [{ op: "replace", value: { active: false } }], }), }); expect( deactivated.response.status, `deactivate user: HTTP ${deactivated.response.status} ${deactivated.text.slice(0, 300)}`, ).toBe(204); // SCIM writes bypass the 60s organizationContext cache TTL. const orgAfterDeprovision = await eventually( () => denFetch(den.ref, "/v1/org", { headers: adminHeaders }), { within: 90_000, label: "SCIM-deprovisioned member to leave organization context", until: ({ response, body }) => response.ok && !orgHasJoinedMember(body, managedEmail), }, ); const passwordAfterDeprovision = await denFetch(den.ref, "/api/auth/sign-in/email", { method: "POST", body: JSON.stringify({ email: managedEmail, password: "still-not-a-scim-password" }), }); const resurrectionInvite = await denFetch(den.ref, "/v1/invitations", { method: "POST", headers: adminHeaders, body: JSON.stringify({ email: managedEmail, role: "member" }), }); const resurrectionToken = stringField(resurrectionInvite.body, "inviteToken"); if (!resurrectionInvite.response.ok || !resurrectionToken) { throw new Error(`Resurrection invitation failed to mint: HTTP ${resurrectionInvite.response.status} ${resurrectionInvite.text.slice(0, 500)}`); } const resurrectionSignUp = await denFetch( den.ref, `/api/auth/sign-up/email?invite=${encodeURIComponent(resurrectionToken)}`, { method: "POST", body: JSON.stringify({ email: managedEmail, name: "Avery Nguyen", password: "PasswordMustNotWork123!" }), }, ); const resurrectionSessionToken = stringField(resurrectionSignUp.body, "token"); const explicitResurrection = resurrectionSessionToken ? await denFetch(den.ref, "/v1/orgs/invitations/accept", { method: "POST", headers: { authorization: `Bearer ${resurrectionSessionToken}` }, body: JSON.stringify({ id: resurrectionToken }), }) : null; const orgAfterInvite = await denFetch(den.ref, "/v1/org", { headers: adminHeaders }); const pendingInvitation = recordsField(orgAfterInvite.body, "invitations") .find((invitation) => invitation.email === managedEmail); const pendingInvitationMember = recordsField(orgAfterInvite.body, "members") .find((member) => isRecord(member.user) && member.user.email === managedEmail); const controlMe = await denFetch(den.ref, "/v1/me", { headers: { authorization: `Bearer ${controlSession.token}` }, }); const controlMeEmail = isRecord(controlMe.body) && isRecord(controlMe.body.user) ? stringField(controlMe.body.user, "email") : null; expect(passwordAfterDeprovision.response.ok).toBe(false); if (explicitResurrection) { expect( explicitResurrection.response.status, `explicit resurrection refusal: HTTP ${explicitResurrection.response.status} ${explicitResurrection.text.slice(0, 300)}`, ).toBe(409); expect(stringField(explicitResurrection.body, "error")).toBe("scim_deprovisioned"); } expect(orgHasJoinedMember(orgAfterDeprovision.body, managedEmail)).toBe(false); expect(orgHasJoinedMember(orgAfterInvite.body, managedEmail)).toBe(false); expect(stringField(pendingInvitation, "status")).not.toBe("accepted"); expect(pendingInvitationMember?.userId).toBe(null); expect(orgHasJoinedMember(orgAfterInvite.body, control.email)).toBe(true); expect(controlMe.response.status).toBe(200); expect(controlMeEmail).toBe(control.email); evidence.recordAssertionEvidence( "Okta deprovisioning cannot be bypassed and does not touch the control member", `PATCH active=false returned 204; ${managedEmail} has no joined membership after invite-backed sign-up returned ${resurrectionSignUp.response.status}${explicitResurrection ? ` and explicit acceptance was refused with ${stringField(explicitResurrection.body, "error")}` : ""}. Invitations cannot resurrect an IdP-deprovisioned member: the refusal is org-scoped, the invitation remains unaccepted with userId null, and ${control.email}'s existing session and membership remain intact.`, deactivated.response.status === 204 && !passwordAfterDeprovision.response.ok && (!explicitResurrection || (explicitResurrection.response.status === 409 && stringField(explicitResurrection.body, "error") === "scim_deprovisioned")) && !orgHasJoinedMember(orgAfterInvite.body, managedEmail) && stringField(pendingInvitation, "status") !== "accepted" && pendingInvitationMember?.userId === null && orgHasJoinedMember(orgAfterInvite.body, control.email) && controlMe.response.status === 200 && controlMeEmail === control.email, ); // ── Frame 6A: Okta cannot absorb an existing personal account ──────────── const averyReprovision = await scimFetch(den.ref.apiUrl, `${scimBasePath}/Users`, scimToken, { method: "POST", body: JSON.stringify(oktaUser), }); expect( averyReprovision.response.status, `re-provision existing personal account: HTTP ${averyReprovision.response.status} ${averyReprovision.text.slice(0, 300)}`, ).toBe(409); expect( stringField(averyReprovision.body, "scimType"), `re-provision existing personal account semantics: HTTP ${averyReprovision.response.status} ${averyReprovision.text.slice(0, 300)}`, ).toBe("uniqueness"); evidence.recordAssertionEvidence( "The IdP cannot silently absorb a pre-existing personal account", `Re-provisioning ${managedEmail}, now owned by a self-created account, returned SCIM ${averyReprovision.response.status} with scimType=${stringField(averyReprovision.body, "scimType")}.`, averyReprovision.response.status === 409 && stringField(averyReprovision.body, "scimType") === "uniqueness", ); // ── Frame 6B: Okta re-provisioning is the only way back in ─────────────── const blakeEmail = `blake.${runId}@${managedDomain}`; const blakeUser = { schemas: [SCIM_USER_SCHEMA], userName: blakeEmail, name: { givenName: "Blake", familyName: "Morgan" }, emails: [{ primary: true, value: blakeEmail, type: "work" }], externalId: `00u-blake-${runId}`, active: true, displayName: "Blake Morgan", }; const blakeCreated = await scimFetch(den.ref.apiUrl, `${scimBasePath}/Users`, scimToken, { method: "POST", body: JSON.stringify(blakeUser), }); const blakeUserId = stringField(blakeCreated.body, "id"); expect( blakeCreated.response.status, `create fresh SCIM user: HTTP ${blakeCreated.response.status} ${blakeCreated.text.slice(0, 300)}`, ).toBe(201); if (!blakeUserId) { throw new Error(`Fresh SCIM user creation omitted id: HTTP ${blakeCreated.response.status} ${blakeCreated.text.slice(0, 500)}`); } // SCIM writes bypass the 60s organizationContext cache TTL. await eventually( () => denFetch(den.ref, "/v1/org", { headers: adminHeaders }), { within: 90_000, label: "fresh SCIM member to join organization context", until: ({ response, body }) => response.ok && orgHasJoinedMember(body, blakeEmail), }, ); const blakeDeactivated = await scimFetch( den.ref.apiUrl, `${scimBasePath}/Users/${encodeURIComponent(blakeUserId)}`, scimToken, { method: "PATCH", body: JSON.stringify({ schemas: [SCIM_PATCH_SCHEMA], Operations: [{ op: "replace", value: { active: false } }], }), }, ); expect( blakeDeactivated.response.status, `deactivate fresh SCIM user: HTTP ${blakeDeactivated.response.status} ${blakeDeactivated.text.slice(0, 300)}`, ).toBe(204); // SCIM writes bypass the 60s organizationContext cache TTL. await eventually( () => denFetch(den.ref, "/v1/org", { headers: adminHeaders }), { within: 90_000, label: "deactivated fresh SCIM member to leave organization context", until: ({ response, body }) => response.ok && !orgHasJoinedMember(body, blakeEmail), }, ); const oldBlakeGet = await scimFetch( den.ref.apiUrl, `${scimBasePath}/Users/${encodeURIComponent(blakeUserId)}`, scimToken, ); expect( oldBlakeGet.response.status, `old fresh-user id after deprovision: HTTP ${oldBlakeGet.response.status} ${oldBlakeGet.text.slice(0, 300)}`, ).toBe(404); const blakeReprovisioned = await scimFetch(den.ref.apiUrl, `${scimBasePath}/Users`, scimToken, { method: "POST", body: JSON.stringify(blakeUser), }); const blakeReprovisionedId = stringField(blakeReprovisioned.body, "id"); expect( blakeReprovisioned.response.status, `re-provision fresh SCIM user: HTTP ${blakeReprovisioned.response.status} ${blakeReprovisioned.text.slice(0, 300)}`, ).toBe(201); expect( blakeReprovisionedId, `re-provisioned fresh-user id: HTTP ${blakeReprovisioned.response.status} ${blakeReprovisioned.text.slice(0, 300)}`, ).not.toBe(blakeUserId); if (!blakeReprovisionedId) { throw new Error(`SCIM re-provisioning omitted id: HTTP ${blakeReprovisioned.response.status} ${blakeReprovisioned.text.slice(0, 500)}`); } // Re-provisioning clears removal memory; the 60s organizationContext cache still applies. const orgAfterBlakeReprovision = await eventually( () => denFetch(den.ref, "/v1/org", { headers: adminHeaders }), { within: 90_000, label: "re-provisioned fresh SCIM member to join organization context", until: ({ response, body }) => response.ok && orgHasJoinedMember(body, blakeEmail), }, ); const blakeReprovisionedGet = await scimFetch( den.ref.apiUrl, `${scimBasePath}/Users/${encodeURIComponent(blakeReprovisionedId)}`, scimToken, ); const blakePassword = await denFetch(den.ref, "/api/auth/sign-in/email", { method: "POST", body: JSON.stringify({ email: blakeEmail, password: "PasswordMustStillNotWork123!" }), }); const allAfterBlakeReprovision = await scimFetch(den.ref.apiUrl, `${scimBasePath}/Users`, scimToken); expect(orgHasJoinedMember(orgAfterBlakeReprovision.body, blakeEmail)).toBe(true); expect( blakeReprovisionedGet.response.status, `get re-provisioned fresh user: HTTP ${blakeReprovisionedGet.response.status} ${blakeReprovisionedGet.text.slice(0, 300)}`, ).toBe(200); expect( isRecord(blakeReprovisionedGet.body) && blakeReprovisionedGet.body.active, `re-provisioned fresh user active shape: HTTP ${blakeReprovisionedGet.response.status} ${blakeReprovisionedGet.text.slice(0, 300)}`, ).toBe(true); expect( numberField(allAfterBlakeReprovision.body, "totalResults"), `list after fresh-user re-provisioning: HTTP ${allAfterBlakeReprovision.response.status} ${allAfterBlakeReprovision.text.slice(0, 300)}`, ).toBe(1); expect(blakePassword.response.ok).toBe(false); evidence.recordAssertionEvidence( "Okta re-provisioning is the only way back in and still grants no password access", `Okta deprovisioned ${blakeEmail}, leaving old id ${blakeUserId} at 404, then POSTed the same userName/externalId into new id ${blakeReprovisionedId}; the new identity joined active with totalResults=1, while password sign-in returned ${blakePassword.response.status}.`, oldBlakeGet.response.status === 404 && blakeReprovisioned.response.status === 201 && blakeReprovisionedId !== blakeUserId && orgHasJoinedMember(orgAfterBlakeReprovision.body, blakeEmail) && isRecord(blakeReprovisionedGet.body) && blakeReprovisionedGet.body.active === true && numberField(allAfterBlakeReprovision.body, "totalResults") === 1 && !blakePassword.response.ok, ); });