diff --git a/apps/docs/content/run/self-host.mdx b/apps/docs/content/run/self-host.mdx index 50cb50a526..e5056dca8d 100644 --- a/apps/docs/content/run/self-host.mdx +++ b/apps/docs/content/run/self-host.mdx @@ -40,7 +40,7 @@ These settings override the defaults: | `DO_NOT_TRACK` or `EXECUTOR_DISABLE_ANALYTICS` | no | Set to `1` to turn off anonymous usage analytics and feedback. See [usage analytics](/run/usage-analytics). | | `SSO_DISCOVERY_URL` | no | OIDC discovery document. Leave it empty to keep SSO off. | | `SSO_CLIENT_ID`, `SSO_CLIENT_SECRET` | no | Credentials for that identity provider. | -| `SSO_ALLOWED_DOMAINS` | no | Comma-separated email domains allowed to join through SSO. | +| `SSO_ALLOWED_DOMAINS` | no | Comma-separated email domains whose members may sign in with SSO. | | `EXECUTOR_TRACE_SAMPLE_RATE` | no | Fraction of ordinary request traces to record, from `0` to `1`. The default is `0.1`; `1` records every trace. See below. | | `EXECUTOR_TRACE_SLOW_MS` | no | Record every trace that runs at least this many milliseconds. The default is `1000`. | @@ -122,8 +122,9 @@ docker stop executor-v2 ``` The first person to finish setup becomes the owner, and one organization is -created. After that, people join through invitations, or through SSO when you -have configured it. +created. After that, people join only through invitations. SSO signs in people +who already belong to an organization and have connected SSO on their account's +Security page; it never adds anyone to an organization. ## Deploy on Railway diff --git a/apps/hosted/self-host/README.md b/apps/hosted/self-host/README.md index 27889e6d1a..a9f9597fc2 100644 --- a/apps/hosted/self-host/README.md +++ b/apps/hosted/self-host/README.md @@ -155,6 +155,12 @@ Motel volumes separate; the entrypoint prepares their ownership for the non-root server user. Do not remove either volume during updates. Product upgrades do not import old Motel data. +The database records the organization setup created as the instance's root. +An upgrade from an older release records it when the database has exactly one +organization, and leaves it unset otherwise. Releases before the one that added +the record refuse every sign-in once a database holds a second organization, so +do not roll back below it after one exists. + ### Trace sampling Recording every span made Motel the busiest process under load, so the product diff --git a/apps/hosted/self-host/src/implementation/auth-options.ts b/apps/hosted/self-host/src/implementation/auth-options.ts index df239509f9..e33a257785 100644 --- a/apps/hosted/self-host/src/implementation/auth-options.ts +++ b/apps/hosted/self-host/src/implementation/auth-options.ts @@ -4,6 +4,7 @@ import { genericOAuth } from "better-auth/plugins/generic-oauth"; import { organization } from "better-auth/plugins/organization"; import { Config, Effect, Option, Redacted, Schema } from "effect"; import { FetchHttpClient, HttpClient, HttpClientResponse } from "effect/http"; +import { selfHostRootSchema } from "./root-organization.ts"; const httpsUrl = (message: string) => Schema.String.check( @@ -125,6 +126,7 @@ export const selfHostAuthOptions = ( emailAndPassword: { enabled: true, disableSignUp: true }, plugins: [ organization({ disableOrganizationDeletion: true, allowUserToCreateOrganization: false }), + selfHostRootSchema, ...base.plugins, genericOAuth({ config: diff --git a/apps/hosted/self-host/src/implementation/database-schema.ts b/apps/hosted/self-host/src/implementation/database-schema.ts index 45b44fa1a8..3b40c71899 100644 --- a/apps/hosted/self-host/src/implementation/database-schema.ts +++ b/apps/hosted/self-host/src/implementation/database-schema.ts @@ -6,6 +6,7 @@ import { makeRegistryStorage } from "@executor-js/app-registry"; import { Effect, Layer, Redacted } from "effect"; import { AuthDatabase } from "../contracts/database.ts"; import { makeAuthDatabase } from "./auth-database.ts"; +import { migrateSelfHostSteps } from "./root-organization.ts"; /** Acquire one engine and initialize both schemas before exposing services. */ export const selfHostDatabaseSchema = Layer.effect( AuthDatabase, @@ -21,7 +22,10 @@ export const selfHostDatabaseSchema = Layer.effect( database, secret: Redacted.value(settings.secret), }, - registry.migrate.pipe(Effect.mapError(migrationFailed("product"))), + registry.migrate.pipe( + Effect.mapError(migrationFailed("product")), + Effect.andThen(migrateSelfHostSteps), + ), ); return database; }), diff --git a/apps/hosted/self-host/src/implementation/registration.ts b/apps/hosted/self-host/src/implementation/registration.ts index 411874678d..c1da1f3fdf 100644 --- a/apps/hosted/self-host/src/implementation/registration.ts +++ b/apps/hosted/self-host/src/implementation/registration.ts @@ -1,9 +1,5 @@ /** Atomic setup and invitation redemption over Better Auth's transaction adapter. */ -import { - defineRequestState, - getCurrentAdapter, - runWithTransaction, -} from "@better-auth/core/context"; +import { getCurrentAdapter, runWithTransaction } from "@better-auth/core/context"; import type { BetterAuthOptions, BetterAuthPlugin, @@ -15,14 +11,8 @@ import { SsoRefusal } from "@executor-js/hosted-self-host-web/sso"; import { Cause, Effect, Exit, Schema } from "effect"; import { z } from "zod"; import type { selfHostAuthSettings } from "./auth-options.ts"; +import { rootModel, rootRecordId } from "./root-organization.ts"; -const admittedSsoUser = defineRequestState(() => false); - -const User = Schema.Struct({ - id: Schema.String, - email: Schema.String, - emailVerified: Schema.Boolean, -}); const Organization = Schema.Struct({ id: Schema.String }); const Invitation = Schema.Struct({ id: Schema.String, @@ -70,21 +60,12 @@ const adapterFor = (ctx: GenericEndpointContext) => call(() => getCurrentAdapter(ctx.context.adapter)); const isSsoCallback = (ctx: GenericEndpointContext) => ctx.path === "/callback/:id" && ctx.params?.id === "sso"; +const notMember = "Your account is not a member of this instance."; -const instanceOrganization = (ctx: GenericEndpointContext) => - Effect.gen(function* () { - const adapter = yield* adapterFor(ctx); - const organizations = yield* call(() => adapter.findMany({ model: "organization", limit: 2 })); - if (organizations.length !== 1) { - const message = "The instance must have exactly one organization. Contact its administrator."; - return yield* isSsoCallback(ctx) - ? refuseSso(SsoRefusal.instanceNotReady, message) - : deny(message); - } - return yield* decode(Organization, organizations[0]); - }); - -/** Reject non-SSO user creation; only verified, allowlisted OIDC users can join automatically. */ +/** + * Invitations are the only way in after setup. SSO signs in people who already belong to an + * organization; it never creates an account or a membership. + */ export const selfHostUserHooks = (settings: Effect.Success) => ({ user: { @@ -95,7 +76,7 @@ export const selfHostUserHooks = (settings: Effect.Success admittedSsoUser.set(true)); + const adapter = yield* adapterFor(ctx); + if ((yield* call(() => adapter.count({ model: "organization" }))) === 0) + return yield* refuseSso( + SsoRefusal.instanceNotReady, + "This instance is not set up yet.", + ); + return yield* refuseSso(SsoRefusal.notMember, notMember); }), ), }, @@ -120,55 +106,17 @@ export const selfHostUserHooks = (settings: Effect.Success adapter.findOne({ model: "member", - where: [ - { field: "userId", value: session.userId }, - { field: "organizationId", value: org.id }, - ], + where: [{ field: "userId", value: session.userId }], + select: ["id"], }), ); - if (member === null) { - // Re-check the policy on every SSO login; no email/password login can auto-join. - if (!isSsoCallback(ctx) || settings.sso === null) - return yield* deny("Your account is not a member of this instance."); - if (!(yield* call(() => admittedSsoUser.get()))) - return yield* refuseSso( - SsoRefusal.notMember, - "Your account is not a member of this instance.", - ); - const user = yield* call(() => - adapter.findOne({ - model: "user", - where: [{ field: "id", value: session.userId }], - }), - ).pipe(Effect.flatMap((value) => decode(User, value))); - const domain = user.email.toLowerCase().split("@")[1]; - if (!user.emailVerified) - return yield* refuseSso( - SsoRefusal.emailUnverified, - "The identity provider did not verify this email address.", - ); - if (!domain || !settings.sso.allowedDomains.includes(domain)) - return yield* refuseSso( - SsoRefusal.domainNotAllowed, - "Use an account from an approved SSO domain.", - ); - yield* call(() => - adapter.create({ - model: "member", - data: { - organizationId: org.id, - userId: user.id, - role: "member", - createdAt: new Date(), - }, - }), - ); - } - return { data: { ...session, activeOrganizationId: org.id } }; + if (member === null) + return yield* isSsoCallback(ctx) + ? refuseSso(SsoRefusal.notMember, notMember) + : deny(notMember); }), ), }, @@ -209,10 +157,16 @@ const register = ( }, }), ).pipe(Effect.flatMap((value) => decode(Organization, value))); + yield* call(() => + adapter.create({ + model: rootModel, + data: { id: rootRecordId, organizationId: org.id, createdAt: new Date() }, + forceAllowId: true, + }), + ); organizationId = org.id; role = "owner"; } else { - const org = yield* instanceOrganization(ctx); const invitation = yield* call(() => adapter.findOne({ model: "invitation", @@ -222,13 +176,12 @@ const register = ( if ( invitation.status !== "pending" || invitation.expiresAt.getTime() <= Date.now() || - invitation.email.toLowerCase() !== email || - invitation.organizationId !== org.id + invitation.email.toLowerCase() !== email ) return yield* deny( "This invitation is invalid, expired, or for a different email.", ); - organizationId = org.id; + organizationId = invitation.organizationId; role = invitation.role; yield* call(() => adapter.update({ diff --git a/apps/hosted/self-host/src/implementation/root-organization.ts b/apps/hosted/self-host/src/implementation/root-organization.ts new file mode 100644 index 0000000000..303254e9e8 --- /dev/null +++ b/apps/hosted/self-host/src/implementation/root-organization.ts @@ -0,0 +1,47 @@ +/** + * The instance's root organization: the one setup created. Its owners control the instance. The + * record is written once, by setup or by the upgrade step below, and never inferred from dates or + * names. A database without it has no root, and every root-only operation is refused. + */ +import type { BetterAuthPlugin } from "better-auth"; +import { migrateProductSteps } from "@executor-js/hosted-server/migrations"; +import { Effect } from "effect"; +import { SqlClient } from "effect/sql"; + +/** The only ID the record ever has, so the primary key admits one root. */ +export const rootRecordId = "root"; +export const rootModel = "selfHostRoot"; + +/** Must follow the organization plugin: Better Auth creates tables in plugin order. */ +export const selfHostRootSchema = { + id: "executor-self-host-root", + schema: { + [rootModel]: { + fields: { + organizationId: { + type: "string", + required: true, + unique: true, + references: { model: "organization", field: "id", onDelete: "restrict" }, + }, + createdAt: { type: "date", required: true }, + }, + }, + }, +} satisfies BetterAuthPlugin; + +/** + * An install from before the record had exactly one organization, its setup's, since nothing + * else could create one. Any other count leaves the root unset. + */ +const recordSoleOrganization = Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* sql`insert into ${sql(rootModel)} ("id", "organizationId", "createdAt") + select ${rootRecordId}, "id", now() from "organization" + where (select count(*) from "organization") = 1 + on conflict do nothing`; +}); + +export const migrateSelfHostSteps = migrateProductSteps("private_self_host_migrations", { + "1_root_organization": recordSoleOrganization, +}); diff --git a/apps/hosted/self-host/web/src/contracts/sso.ts b/apps/hosted/self-host/web/src/contracts/sso.ts index 2bce3d653a..39a82b02bc 100644 --- a/apps/hosted/self-host/web/src/contracts/sso.ts +++ b/apps/hosted/self-host/web/src/contracts/sso.ts @@ -15,7 +15,7 @@ const explanations: Readonly> = { [SsoRefusal.domainNotAllowed]: "This email is not from a domain approved for SSO on this instance. Use an approved account or contact your administrator.", [SsoRefusal.notMember]: - "Your account is not a member of this instance. Contact your administrator.", + "Your account is not a member of this instance. Ask an administrator to invite you.", [SsoRefusal.instanceNotReady]: "This instance is not set up yet. Ask your administrator to finish setup.", // Better Auth links SSO only to an account whose email was verified, and password accounts diff --git a/apps/hosted/server/src/implementation/migrations.ts b/apps/hosted/server/src/implementation/migrations.ts index 95733bbab1..4edda03a7a 100644 --- a/apps/hosted/server/src/implementation/migrations.ts +++ b/apps/hosted/server/src/implementation/migrations.ts @@ -71,7 +71,7 @@ export const migrationFailed = * transaction. The caller owns startup access or the hosted migration lock. */ export const migrateProductSteps = ( - table: "private_hosted_migrations" | "private_cloud_migrations", + table: "private_hosted_migrations" | "private_cloud_migrations" | "private_self_host_migrations", steps: Record>, ) => Effect.gen(function* () { diff --git a/e2e/test-plan.ts b/e2e/test-plan.ts index 52bbe8dfc6..a9e05a260a 100644 --- a/e2e/test-plan.ts +++ b/e2e/test-plan.ts @@ -179,6 +179,42 @@ export const scenarios = plan({ local: na("Local does not publish apps."), }, }, + selfHostRootRecord: { + fixtures: "actors", + legacyStorage: true, + file: "self-host-many-organizations.spec.ts", + title: + "self-host setup records its organization as the root, and an upgrade records an install's only organization once", + targets: { + "self-host": scheduled, + cloud: na("Cloud has no root organization; every team is its own."), + local: na("Local has no hosted organizations."), + }, + }, + selfHostRootUnsetAmongSeveral: { + fixtures: "actors", + legacyStorage: true, + file: "self-host-many-organizations.spec.ts", + title: + "a self-host upgraded with several organizations and no root record leaves the root unset and keeps serving each", + targets: { + "self-host": scheduled, + cloud: na("Cloud has no root organization; every team is its own."), + local: na("Local has no hosted organizations."), + }, + }, + selfHostSeveralOrganizations: { + fixtures: "actors", + legacyStorage: true, + file: "self-host-many-organizations.spec.ts", + title: + "a self-host with several organizations signs in their members, admits invitations into the inviting organization and refuses anyone without one", + targets: { + "self-host": scheduled, + cloud: na("Cloud invitations need a verified recipient instead of password registration."), + local: na("Local has no hosted organizations."), + }, + }, registryMigration: { fixtures: "actors", legacyStorage: true, @@ -5605,6 +5641,16 @@ export const scenarios = plan({ local: na("Local has no hosted sign-in."), }, }, + selfHostSsoNoAutoJoin: { + file: "self-host-sso.spec.ts", + title: + "Self-host SSO admits no one automatically and refuses a verified, approved address without an invitation", + targets: { + "self-host": scheduled, + cloud: na("Cloud customer SSO has its own OIDC scenario."), + local: na("Local has no sign-in or SSO."), + }, + }, selfHostSsoUnverifiedEmail: { file: "self-host-sso.spec.ts", title: "Self-host SSO refuses an email the provider does not verify and says why", diff --git a/e2e/tests/self-host-many-organizations.spec.ts b/e2e/tests/self-host-many-organizations.spec.ts new file mode 100644 index 0000000000..ea625269a3 --- /dev/null +++ b/e2e/tests/self-host-many-organizations.spec.ts @@ -0,0 +1,214 @@ +/** + * A self-host database can hold several organizations before this server lets anyone create one, + * as after a rollback from a release that did. Their rows are written directly. The root record + * names the organization setup created; an upgrade records an install's only organization and + * never picks one among several. + */ +import { expect, layer } from "@effect/vitest"; +import { Effect, Schema } from "effect"; +import { Actors, password } from "../support/actors.ts"; +import { Api, body, type Session } from "../support/api.ts"; +import { HostedLive, withHostedCase } from "../support/case.ts"; +import { Organization, Resource } from "../support/contracts.ts"; +import { legacyStorage } from "../support/legacy-storage.ts"; +import { serverControl } from "../support/server-control.ts"; +import { scenarios } from "../test-plan.ts"; + +const Root = Schema.Array(Schema.Struct({ id: Schema.String, organizationId: Schema.String })); +const Journal = Schema.Array(Schema.Struct({ name: Schema.String })); +const Access = Schema.Struct({ organization: Schema.String, role: Schema.String }); + +const rootQueries = [ + { sql: `select "id", "organizationId", "createdAt" from "selfHostRoot"` }, + { sql: "select name from private_self_host_migrations order by migration_id" }, +] as const; + +const decodeRoot = ([root = [], journal = []]: ReadonlyArray< + ReadonlyArray>> +>) => + Effect.gen(function* () { + return { + root: yield* Schema.decodeUnknownEffect(Root)(root), + createdAt: root.map((row) => row.createdAt), + journal: (yield* Schema.decodeUnknownEffect(Journal)(journal)).map((row) => row.name), + }; + }); + +/** The database of an install from before the root record existed. */ +const beforeRootRecord = [ + { sql: `drop table "selfHostRoot"` }, + { sql: "drop table private_self_host_migrations" }, +] as const; + +const secondOrganization = (ownerEmail: string) => + [ + { + sql: `insert into "organization" ("id", "name", "slug", "createdAt") values ('org-second', 'Second team', 'second-team', now())`, + }, + { + sql: `insert into "member" ("id", "organizationId", "userId", "role", "createdAt") + select 'member-second-owner', 'org-second', "id", 'owner', now() from "user" where "email" = $1`, + params: [ownerEmail], + }, + ] as const; + +const signIn = (email: string) => + Effect.gen(function* () { + const api = yield* Api; + const session = yield* api.session(); + const response = yield* api.request(session, "POST", "/api/auth/sign-in/email", { + email, + password, + }); + return { session, status: response.status }; + }); + +const access = (session: Session, organization: string) => + Effect.flatMap(Api, (api) => + api.request(session, "GET", `/api/organizations/${organization}/access`), + ); + +layer(HostedLive, { excludeTestServices: true })("Self-host with several organizations", (it) => { + it.effect(scenarios.selfHostRootRecord.title, (context) => + withHostedCase( + context, + Effect.gen(function* () { + const actors = yield* Actors; + const root = { id: "root", organizationId: actors.organization.id }; + const setUp = yield* legacyStorage(rootQueries).pipe(Effect.flatMap(decodeRoot)); + expect(setUp.root).toEqual([root]); + expect(setUp.journal).toEqual(["root_organization"]); + + yield* legacyStorage(beforeRootRecord); + yield* serverControl("start"); + const upgraded = yield* legacyStorage(rootQueries).pipe(Effect.flatMap(decodeRoot)); + expect(upgraded.root).toEqual([root]); + expect(upgraded.journal).toEqual(["root_organization"]); + + yield* serverControl("start"); + const repeated = yield* legacyStorage(rootQueries).pipe(Effect.flatMap(decodeRoot)); + expect(repeated).toEqual(upgraded); + yield* serverControl("start"); + expect((yield* access(actors.owner, actors.organization.id)).status).toBe(200); + }), + ), + ); + + it.effect(scenarios.selfHostRootUnsetAmongSeveral.title, (context) => + withHostedCase( + context, + Effect.gen(function* () { + const actors = yield* Actors; + yield* legacyStorage([...beforeRootRecord, ...secondOrganization("owner@example.test")]); + yield* serverControl("start"); + const upgraded = yield* legacyStorage(rootQueries).pipe(Effect.flatMap(decodeRoot)); + expect(upgraded.root).toEqual([]); + expect(upgraded.journal).toEqual(["root_organization"]); + yield* serverControl("start"); + expect((yield* access(actors.owner, actors.organization.id)).status).toBe(200); + expect((yield* access(actors.owner, "org-second")).status).toBe(200); + }), + ), + ); + + it.effect(scenarios.selfHostSeveralOrganizations.title, (context) => + withHostedCase( + context, + Effect.gen(function* () { + const api = yield* Api, + actors = yield* Actors; + const root = actors.organization.id; + yield* legacyStorage(secondOrganization("owner@example.test")); + yield* serverControl("start"); + + const owner = yield* signIn("owner@example.test"); + expect(owner.status).toBe(200); + const listed = yield* body( + Schema.Array(Organization), + yield* api.request(owner.session, "GET", "/api/auth/organization/list"), + ); + expect(listed.map((organization) => organization.id).toSorted()).toEqual( + [root, "org-second"].toSorted(), + ); + expect((yield* access(actors.member, root)).status).toBe(200); + + // A new person joins the organization whose invitation they redeem, and only that one. + const invitation = yield* body( + Resource, + yield* api.request(owner.session, "POST", "/api/auth/organization/invite-member", { + organizationId: "org-second", + email: "newcomer@example.test", + role: "member", + }), + ); + const newcomer = yield* api.session(); + const joined = yield* api.request(newcomer, "POST", "/api/auth/self-host/register", { + invitation: invitation.id, + email: "newcomer@example.test", + name: "New Comer", + password, + }); + expect(joined.status).toBe(200); + const newcomerOrganizations = yield* body( + Schema.Array(Organization), + yield* api.request(newcomer, "GET", "/api/auth/organization/list"), + ); + expect(newcomerOrganizations.map((organization) => organization.id)).toEqual([ + "org-second", + ]); + expect( + yield* body(Access, yield* access(newcomer, "org-second")).pipe( + Effect.map((value) => value.role), + ), + ).toBe("member"); + expect((yield* access(newcomer, root)).status).toBe(403); + + // A member of the root accepts an invitation into the second organization with its role. + const adminInvitation = yield* body( + Resource, + yield* api.request(owner.session, "POST", "/api/auth/organization/invite-member", { + organizationId: "org-second", + email: "admin@example.test", + role: "admin", + }), + ); + const accepted = yield* api.request( + actors.admin, + "POST", + "/api/auth/organization/accept-invitation", + { invitationId: adminInvitation.id }, + ); + expect(accepted.status).toBe(200); + expect((yield* body(Access, yield* access(actors.admin, "org-second"))).role).toBe("admin"); + expect((yield* body(Access, yield* access(actors.admin, root))).role).toBe("admin"); + + // Someone who belongs to no organization cannot sign in. + const removed = yield* api.request( + owner.session, + "POST", + "/api/auth/organization/remove-member", + { organizationId: root, memberIdOrEmail: "member@example.test" }, + ); + expect(removed.status).toBe(200); + expect((yield* signIn("member@example.test")).status).toBe(403); + + // Setup stays closed however many organizations exist, across a restart. + const config = yield* body( + Schema.Struct({ setup: Schema.Boolean }), + yield* api.request(newcomer, "GET", "/api/auth/self-host/config"), + ); + expect(config.setup).toBe(false); + const anonymous = yield* api.session(); + const setup = yield* api.request(anonymous, "POST", "/api/auth/self-host/setup", { + name: "Intruder", + email: "intruder@example.test", + password, + organizationName: "Takeover", + }); + expect(setup.status).toBe(403); + yield* serverControl("restart"); + expect((yield* signIn("owner@example.test")).status).toBe(200); + }), + ), + ); +}); diff --git a/e2e/tests/self-host-sso.spec.ts b/e2e/tests/self-host-sso.spec.ts index f4d3d0cada..64ec2e1887 100644 --- a/e2e/tests/self-host-sso.spec.ts +++ b/e2e/tests/self-host-sso.spec.ts @@ -7,6 +7,7 @@ import { expect, layer } from "@effect/vitest"; import { Effect, FileSystem, Schema } from "effect"; import type { Page } from "playwright"; +import { password } from "../support/actors.ts"; import { Browser } from "../support/browser.ts"; import { TestLive, withCase } from "../support/case.ts"; import { startControlledSelfHost } from "../support/managed-server.ts"; @@ -56,7 +57,7 @@ const startSsoInstance = (mode: IdentityMode) => }); yield* serverControl("start"); }).pipe(Effect.provideService(Target, instance)); - // SSO admits members into the instance's organization, so the operator sets it up first. + // SSO signs in only people an organization admitted, so the operator sets one up first. expect( yield* browser.use("Set up the instance", (page) => page @@ -122,6 +123,71 @@ const expectExplainedRejection = (origin: string, step: string, explanation: str expect(yield* session(origin)).toBeNull(); }); +const notMember = + "Your account is not a member of this instance. Ask an administrator to invite you."; + +/** Invitations are the only way in: the owner, still signed in from setup, invites `email`. */ +const invite = (origin: string, email: string) => + Effect.gen(function* () { + const browser = yield* Browser; + const [organization] = yield* browser + .use("Read the organization", (page) => + page + .context() + .request.get(`${origin}/api/auth/organization/list`) + .then((response) => response.json()), + ) + .pipe( + Effect.flatMap( + Schema.decodeUnknownEffect(Schema.Array(Schema.Struct({ id: Schema.String }))), + ), + ); + if (organization === undefined) return yield* Effect.die("Setup created no organization"); + return yield* browser + .use("Invite the SSO member", (page) => + page + .context() + .request.post(`${origin}/api/auth/organization/invite-member`, { + headers: { origin }, + data: { organizationId: organization.id, email, role: "member" }, + }) + .then((response) => response.json()), + ) + .pipe(Effect.flatMap(Schema.decodeUnknownEffect(Schema.Struct({ id: Schema.String })))); + }); + +/** The invited person registers with a password, then connects SSO, signed in as `mode` says. */ +const connectInvited = ( + origin: string, + idp: Effect.Success>, + invitation: { readonly id: string }, + email: string, + mode: IdentityMode, +) => + Effect.gen(function* () { + const browser = yield* Browser; + yield* browser.use("Sign out", (page) => page.context().clearCookies()); + expect( + yield* browser.use("The invited member registers", (page) => + page + .context() + .request.post(`${origin}/api/auth/self-host/register`, { + headers: { origin }, + data: { invitation: invitation.id, email, name: "SSO Member", password }, + }) + .then((response) => response.status()), + ), + ).toBe(200); + yield* idp.configure(email, mode); + yield* browser.use("Open Security", (page) => page.goto(`${origin}/account/security`)); + yield* browser.use("Connect the member's SSO account", (page) => + page.getByRole("button", { name: "Connect SSO", exact: true }).click(), + ); + yield* browser.use("Security shows SSO connected", (page) => + page.getByText("Connected. You can sign in with SSO or with your password.").waitFor(), + ); + }); + const unverified = "Your identity provider did not confirm that this email address is verified. Ask your administrator to have the provider mark it verified."; @@ -185,12 +251,13 @@ layer(TestLive, { excludeTestServices: true })("Self-host SSO", (it) => { Effect.gen(function* () { const browser = yield* Browser; const { idp, origin } = yield* startSsoInstance("valid"); + const invitation = yield* invite(origin, "member@sso.example.test"); // Keys are fetched on first use and then cached, so the key redirect comes first. yield* idp.configure("jwks-redirect@sso.example.test", "jwks-redirect"); yield* expectRejectedSignIn(origin, "Signing keys redirected to cleartext"); yield* idp.configure("token-redirect@sso.example.test", "token-redirect"); yield* expectRejectedSignIn(origin, "Token exchange redirected to cleartext"); - yield* idp.configure("member@sso.example.test", "valid"); + yield* connectInvited(origin, idp, invitation, "member@sso.example.test", "valid"); yield* continueWithSso(origin, "HTTPS provider"); yield* browser.use("The admitted member opens the organization", (page) => page.waitForURL(`${origin}/org/*/**`), @@ -208,13 +275,23 @@ layer(TestLive, { excludeTestServices: true })("Self-host SSO", (it) => { Effect.gen(function* () { const browser = yield* Browser; const { idp, origin } = yield* startSsoInstance("valid"); + const invitation = yield* invite(origin, "member@sso.example.test"); // UserInfo about another subject does not verify this one's address. yield* idp.configure("other-subject@sso.example.test", "userinfo-other-subject"); yield* expectExplainedRejection(origin, "UserInfo for another subject", unverified); yield* browser.checkpoint("SSO refused when UserInfo names another subject"); + // Verification passes, so the refusal is the missing membership, not the address. yield* idp.configure("member@sso.example.test", "userinfo-verified"); + yield* expectExplainedRejection(origin, "Verified at UserInfo, not invited", notMember); + yield* connectInvited( + origin, + idp, + invitation, + "member@sso.example.test", + "userinfo-verified", + ); yield* continueWithSso(origin, "Email verified only at UserInfo"); yield* browser.use("The member leaves sign-in", (page) => page.waitForURL( @@ -232,6 +309,48 @@ layer(TestLive, { excludeTestServices: true })("Self-host SSO", (it) => { ), ); + it.effect(scenarios.selfHostSsoNoAutoJoin.title, (context) => + withCase( + context, + Effect.gen(function* () { + const browser = yield* Browser; + const { origin } = yield* startSsoInstance("valid"); + yield* expectExplainedRejection( + origin, + "Verified, approved domain, not invited", + notMember, + ); + yield* browser.checkpoint("SSO refuses a verified, approved address without a membership"); + // The refusal created nothing, so the next attempt is refused the same way. + yield* expectExplainedRejection(origin, "The same person tries again", notMember); + const members = yield* browser.use("The owner lists the members", (page) => + page + .context() + .request.post(`${origin}/api/auth/sign-in/email`, { + headers: { origin }, + data: { email: "owner@sso.example.test", password }, + }) + .then(() => page.context().request.get(`${origin}/api/auth/organization/list`)) + .then((response) => response.json()) + .then((organizations: ReadonlyArray<{ readonly id: string }>) => + page + .context() + .request.get( + `${origin}/api/auth/organization/list-members?organizationId=${organizations[0]?.id ?? ""}`, + ), + ) + .then((response) => response.json()), + ); + expect(members).toEqual( + expect.objectContaining({ + total: 1, + members: [expect.objectContaining({ role: "owner" })], + }), + ); + }), + ), + ); + it.effect(scenarios.selfHostSsoUnverifiedEmail.title, (context) => withCase( context,