Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/selfhost-sso-sign-in.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"executor": patch
---

**Self-host: bring-your-own SSO (Google, Okta, any OIDC IdP) with a verified-domain allowlist**

Operators can enable a single OIDC sign-in provider on a self-hosted instance by setting `EXECUTOR_SSO_PROVIDER_ID`, `EXECUTOR_SSO_CLIENT_ID`, `EXECUTOR_SSO_CLIENT_SECRET`, and `EXECUTOR_SSO_ALLOWED_DOMAINS` (comma-separated email domains), plus `EXECUTOR_SSO_DISCOVERY_URL` for providers without a preset (`google` is preset; `EXECUTOR_SSO_PROVIDER_NAME` overrides the button label). The login page renders a "Continue with <provider>" button when configured (discovered through the new unauthenticated `GET /api/auth-config`, which returns provider id + display name only), and the MCP OAuth connect flow's login step gains the same option since it lands on the same page.

The domain allowlist replaces the invite code for SSO sign-ups: a sign-in whose IdP-verified email (`email_verified`) has an allowlisted domain auto-joins the instance organization as a member; unverified emails and any other domain are refused. Enabling the provider without an allowlist is refused at boot, as is a half-configured client id/secret pair, so SSO can never silently become open registration. Email/password sign-in and invite-based signup are unchanged. The end-to-end flow (discovery → redirect → consent → callback → membership) is exercised in tests against an emulated OIDC IdP from `@executor-js/emulate`.
1 change: 1 addition & 0 deletions apps/host-selfhost/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@
},
"devDependencies": {
"@effect/vitest": "catalog:",
"@executor-js/emulate": "^0.14.0",
"@executor-js/vite-plugin": "workspace:*",
"@tailwindcss/vite": "catalog:",
"@tanstack/router-plugin": "^1.167.12",
Expand Down
63 changes: 59 additions & 4 deletions apps/host-selfhost/src/auth/better-auth.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,13 @@
import { betterAuth, type BetterAuthOptions } from "better-auth";
import { APIError } from "better-auth/api";
import { admin, bearer, deviceAuthorization, mcp, organization } from "better-auth/plugins";
import {
admin,
bearer,
deviceAuthorization,
genericOAuth,
mcp,
organization,
} from "better-auth/plugins";
import { apiKey } from "@better-auth/api-key";
import { type Client } from "@libsql/client";
import { LibsqlDialect, type LibsqlDialectConfig } from "@libsql/kysely-libsql";
Expand All @@ -9,6 +16,7 @@ import { Context } from "effect";
import { loadConfig } from "../config";
import { seedOrgAndAdmin } from "./seed";
import { consumeInviteCode, ensureInviteCodeTable, findRedeemableCode } from "./invites";
import { isAdmitted, isOAuthCallback, ssoProviderConfig } from "./sso";

// The self-service signup gate: present only on the live (phase-2) auth
// instance, so the bootstrap seed's `createUser` — which
Expand Down Expand Up @@ -165,6 +173,14 @@ const makeAuthOptions = (client: Client, getOrganizationId: () => string, gate?:
// is the page the user opens to confirm the code — the self-host app serves
// it at /device (this is also the Better Auth default; pinned for clarity).
deviceAuthorization({ verificationUri: "/device" }),
// The operator-configured SSO provider (see config.ts), spoken over plain
// OIDC discovery so Google, Okta, Entra, or any compliant IdP slots in —
// and so tests can point it at an emulated IdP. The domain gate below is
// what admits or refuses the users this creates; enabling the provider
// alone never opens registration. Always in the plugin tuple (an empty
// provider list serves no routes that match) so the inferred `auth.api`
// shape doesn't depend on the environment.
genericOAuth({ config: config.sso ? [ssoProviderConfig(config.sso)] : [] }),
// `consentPage` makes the MCP authorize flow redirect to a human approval
// screen instead of auto-issuing a code — but ONLY when the request
// carries `prompt=consent`. MCP clients don't send that, so the self-host
Expand Down Expand Up @@ -199,8 +215,26 @@ const makeAuthOptions = (client: Client, getOrganizationId: () => string, gate?:
? {
user: {
create: {
before: async (_user, context) => {
if (context?.path !== SIGNUP_PATH) return;
before: async (user, context) => {
if (context?.path !== SIGNUP_PATH) {
// SSO sign-ups arrive on an OAuth callback path; the
// verified-domain allowlist gates them in place of an
// invite code. Server-side creation (the seed, admin
// add-user) passes.
const sso = config.sso;
if (
isOAuthCallback(context?.path) &&
!(sso !== undefined && isAdmitted(sso, user))
) {
// oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: a Better Auth create hook rejects a request by throwing APIError
throw new APIError("FORBIDDEN", {
message: sso
? `Sign-ups are restricted to verified ${sso.allowedDomains.map((d) => `@${d}`).join(", ")} accounts.`
: "SSO sign-up is not enabled on this instance.",
});
}
return;
}
if (await orgHasNoMembers(gate)) return; // first user claims the org
const code = inviteCodeFrom(context);
if (!code) {
Expand All @@ -217,9 +251,30 @@ const makeAuthOptions = (client: Client, getOrganizationId: () => string, gate?:
}
},
after: async (user, context) => {
if (context?.path !== SIGNUP_PATH) return;
const auth = gate.getAuth();
if (!auth) return;
if (context?.path !== SIGNUP_PATH) {
// An SSO user that reached `after` was admitted by
// `before`; joining the instance org as a member is what an
// invite redemption would have done. Server-side creation
// (no callback path) is left alone — the seed manages its
// own membership.
const sso = config.sso;
if (
isOAuthCallback(context?.path) &&
sso !== undefined &&
isAdmitted(sso, user)
) {
await auth.api.addMember({
body: {
userId: user.id,
role: "member",
organizationId: gate.organizationId,
},
});
}
return;
}
// First user into an empty org becomes its owner (no code).
if (await orgHasNoMembers(gate)) {
await auth.api.addMember({
Expand Down
Loading
Loading