Skip to content

Commit b22d536

Browse files
Feat/selfhost google sso (#1834)
* feat(host-selfhost): Google sign-in with domain-allowlist admission * feat(host-selfhost): generic OIDC SSO sign-in with verified-domain admission * refactor(host-selfhost): extract SSO admission helpers, trim comments
1 parent 31a8042 commit b22d536

12 files changed

Lines changed: 620 additions & 68 deletions

File tree

‎.changeset/selfhost-sso-sign-in.md‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
"executor": patch
3+
---
4+
5+
**Self-host: bring-your-own SSO (Google, Okta, any OIDC IdP) with a verified-domain allowlist**
6+
7+
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.
8+
9+
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`.

‎apps/host-selfhost/package.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@
5252
},
5353
"devDependencies": {
5454
"@effect/vitest": "catalog:",
55+
"@executor-js/emulate": "^0.14.0",
5556
"@executor-js/vite-plugin": "workspace:*",
5657
"@tailwindcss/vite": "catalog:",
5758
"@tanstack/router-plugin": "^1.167.12",

‎apps/host-selfhost/src/auth/better-auth.ts‎

Lines changed: 59 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,13 @@
11
import { betterAuth, type BetterAuthOptions } from "better-auth";
22
import { APIError } from "better-auth/api";
3-
import { admin, bearer, deviceAuthorization, mcp, organization } from "better-auth/plugins";
3+
import {
4+
admin,
5+
bearer,
6+
deviceAuthorization,
7+
genericOAuth,
8+
mcp,
9+
organization,
10+
} from "better-auth/plugins";
411
import { apiKey } from "@better-auth/api-key";
512
import { type Client } from "@libsql/client";
613
import { LibsqlDialect, type LibsqlDialectConfig } from "@libsql/kysely-libsql";
@@ -9,6 +16,7 @@ import { Context } from "effect";
916
import { loadConfig } from "../config";
1017
import { seedOrgAndAdmin } from "./seed";
1118
import { consumeInviteCode, ensureInviteCodeTable, findRedeemableCode } from "./invites";
19+
import { isAdmitted, isOAuthCallback, ssoProviderConfig } from "./sso";
1220

1321
// The self-service signup gate: present only on the live (phase-2) auth
1422
// instance, so the bootstrap seed's `createUser` — which
@@ -165,6 +173,14 @@ const makeAuthOptions = (client: Client, getOrganizationId: () => string, gate?:
165173
// is the page the user opens to confirm the code — the self-host app serves
166174
// it at /device (this is also the Better Auth default; pinned for clarity).
167175
deviceAuthorization({ verificationUri: "/device" }),
176+
// The operator-configured SSO provider (see config.ts), spoken over plain
177+
// OIDC discovery so Google, Okta, Entra, or any compliant IdP slots in —
178+
// and so tests can point it at an emulated IdP. The domain gate below is
179+
// what admits or refuses the users this creates; enabling the provider
180+
// alone never opens registration. Always in the plugin tuple (an empty
181+
// provider list serves no routes that match) so the inferred `auth.api`
182+
// shape doesn't depend on the environment.
183+
genericOAuth({ config: config.sso ? [ssoProviderConfig(config.sso)] : [] }),
168184
// `consentPage` makes the MCP authorize flow redirect to a human approval
169185
// screen instead of auto-issuing a code — but ONLY when the request
170186
// carries `prompt=consent`. MCP clients don't send that, so the self-host
@@ -199,8 +215,26 @@ const makeAuthOptions = (client: Client, getOrganizationId: () => string, gate?:
199215
? {
200216
user: {
201217
create: {
202-
before: async (_user, context) => {
203-
if (context?.path !== SIGNUP_PATH) return;
218+
before: async (user, context) => {
219+
if (context?.path !== SIGNUP_PATH) {
220+
// SSO sign-ups arrive on an OAuth callback path; the
221+
// verified-domain allowlist gates them in place of an
222+
// invite code. Server-side creation (the seed, admin
223+
// add-user) passes.
224+
const sso = config.sso;
225+
if (
226+
isOAuthCallback(context?.path) &&
227+
!(sso !== undefined && isAdmitted(sso, user))
228+
) {
229+
// oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: a Better Auth create hook rejects a request by throwing APIError
230+
throw new APIError("FORBIDDEN", {
231+
message: sso
232+
? `Sign-ups are restricted to verified ${sso.allowedDomains.map((d) => `@${d}`).join(", ")} accounts.`
233+
: "SSO sign-up is not enabled on this instance.",
234+
});
235+
}
236+
return;
237+
}
204238
if (await orgHasNoMembers(gate)) return; // first user claims the org
205239
const code = inviteCodeFrom(context);
206240
if (!code) {
@@ -217,9 +251,30 @@ const makeAuthOptions = (client: Client, getOrganizationId: () => string, gate?:
217251
}
218252
},
219253
after: async (user, context) => {
220-
if (context?.path !== SIGNUP_PATH) return;
221254
const auth = gate.getAuth();
222255
if (!auth) return;
256+
if (context?.path !== SIGNUP_PATH) {
257+
// An SSO user that reached `after` was admitted by
258+
// `before`; joining the instance org as a member is what an
259+
// invite redemption would have done. Server-side creation
260+
// (no callback path) is left alone — the seed manages its
261+
// own membership.
262+
const sso = config.sso;
263+
if (
264+
isOAuthCallback(context?.path) &&
265+
sso !== undefined &&
266+
isAdmitted(sso, user)
267+
) {
268+
await auth.api.addMember({
269+
body: {
270+
userId: user.id,
271+
role: "member",
272+
organizationId: gate.organizationId,
273+
},
274+
});
275+
}
276+
return;
277+
}
223278
// First user into an empty org becomes its owner (no code).
224279
if (await orgHasNoMembers(gate)) {
225280
await auth.api.addMember({

0 commit comments

Comments
 (0)