From e2e916d10bb23bf0e3f6173932d5ea7e0c90e587 Mon Sep 17 00:00:00 2001 From: Charlie Date: Fri, 28 Aug 2026 07:37:57 +0000 Subject: [PATCH 1/3] feat(host-selfhost): Google sign-in with domain-allowlist admission --- .changeset/selfhost-google-sign-in.md | 9 ++ apps/host-selfhost/src/auth/better-auth.ts | 80 ++++++++- .../host-selfhost/src/auth/google-sso.test.ts | 152 ++++++++++++++++++ apps/host-selfhost/src/config.ts | 45 ++++++ apps/host-selfhost/src/system/api.ts | 14 +- apps/host-selfhost/src/system/handlers.ts | 9 ++ apps/host-selfhost/web/auth-config.ts | 20 +++ apps/host-selfhost/web/login.tsx | 57 ++++++- 8 files changed, 376 insertions(+), 10 deletions(-) create mode 100644 .changeset/selfhost-google-sign-in.md create mode 100644 apps/host-selfhost/src/auth/google-sso.test.ts create mode 100644 apps/host-selfhost/web/auth-config.ts diff --git a/.changeset/selfhost-google-sign-in.md b/.changeset/selfhost-google-sign-in.md new file mode 100644 index 0000000000..40f09decd3 --- /dev/null +++ b/.changeset/selfhost-google-sign-in.md @@ -0,0 +1,9 @@ +--- +"executor": patch +--- + +**Self-host: bring-your-own Google sign-in with a domain allowlist** + +Operators can enable Google as a login provider on a self-hosted instance by setting `EXECUTOR_GOOGLE_CLIENT_ID`, `EXECUTOR_GOOGLE_CLIENT_SECRET`, and `EXECUTOR_GOOGLE_ALLOWED_DOMAINS` (comma-separated email domains). The login page renders a "Continue with Google" button when the provider is configured (discovered through the new unauthenticated `GET /api/auth-config`, which returns provider ids 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 social sign-ups: a Google sign-in whose email domain is on the list auto-joins the instance organization as a member, and any other domain is refused. Enabling the provider without an allowlist is refused at boot, as is a half-configured client id/secret pair, so Google sign-in can never silently become open registration. Email/password sign-in and invite-based signup are unchanged. diff --git a/apps/host-selfhost/src/auth/better-auth.ts b/apps/host-selfhost/src/auth/better-auth.ts index 604efec01e..46b87ec690 100644 --- a/apps/host-selfhost/src/auth/better-auth.ts +++ b/apps/host-selfhost/src/auth/better-auth.ts @@ -6,7 +6,7 @@ import { type Client } from "@libsql/client"; import { LibsqlDialect, type LibsqlDialectConfig } from "@libsql/kysely-libsql"; import { Context } from "effect"; -import { loadConfig } from "../config"; +import { loadConfig, type GoogleSsoConfig } from "../config"; import { seedOrgAndAdmin } from "./seed"; import { consumeInviteCode, ensureInviteCodeTable, findRedeemableCode } from "./invites"; @@ -25,6 +25,27 @@ interface SignupGate { // creation (the seed, or a future admin "add user") flows through other paths. const SIGNUP_PATH = "/sign-up/email"; +// Better Auth serves the social OAuth callback at `/callback/:providerId` +// (`api/routes/callback` in the installed build) — the only path a +// social-provider user creation arrives on. Server-side creation (the seed, +// admin add-user) never carries it, so it cleanly splits "a stranger signed in +// with Google" from every trusted path. +const isSocialCallback = (path: string | undefined): boolean => + path?.startsWith("/callback/") === true; + +// The domain of a well-formed address, or null — so a malformed email can never +// match an allowlist entry (`emailDomain("@example.com")` is null, not "example.com"). +export const emailDomain = (email: string): string | null => { + const at = email.lastIndexOf("@"); + if (at <= 0 || at === email.length - 1) return null; + return email.slice(at + 1).toLowerCase(); +}; + +const isDomainAdmitted = (sso: GoogleSsoConfig, email: string): boolean => { + const domain = emailDomain(email); + return domain !== null && sso.allowedDomains.includes(domain); +}; + // --------------------------------------------------------------------------- // Better Auth instance over the SAME libSQL CONNECTION as the FumaDB executor // tables ("one connection, two schema regions"). @@ -99,6 +120,19 @@ const makeAuthOptions = (client: Client, getOrganizationId: () => string, gate?: baseURL: config.webBaseUrl, trustedOrigins: [config.webBaseUrl], emailAndPassword: { enabled: true }, + // Google sign-in, when the operator configured it (see config.ts). The + // domain gate below is what admits or refuses the users this creates — + // enabling the provider alone never opens registration. + ...(config.googleSso + ? { + socialProviders: { + google: { + clientId: config.googleSso.clientId, + clientSecret: config.googleSso.clientSecret, + }, + }, + } + : {}), // `apiKey` issues long-lived personal keys (the API-keys page). With // `enableSessionForAPIKeys`, presenting a key resolves to its owner's // session — so a key works as a Bearer token for the API + MCP endpoint. @@ -175,8 +209,25 @@ 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) { + // Social sign-ups arrive on the OAuth callback path; the + // domain allowlist gates them in place of an invite code. + // Server-side creation (the seed, admin add-user) passes. + const sso = config.googleSso; + if ( + isSocialCallback(context?.path) && + !(sso !== undefined && isDomainAdmitted(sso, user.email)) + ) { + // 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 ${sso.allowedDomains.map((d) => `@${d}`).join(", ")} accounts.` + : "Social 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) { @@ -193,9 +244,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) { + // A social user that reached `after` was domain-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.googleSso; + if ( + isSocialCallback(context?.path) && + sso !== undefined && + isDomainAdmitted(sso, user.email) + ) { + 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({ diff --git a/apps/host-selfhost/src/auth/google-sso.test.ts b/apps/host-selfhost/src/auth/google-sso.test.ts new file mode 100644 index 0000000000..95e387a1cf --- /dev/null +++ b/apps/host-selfhost/src/auth/google-sso.test.ts @@ -0,0 +1,152 @@ +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { afterAll, describe, expect, it, test } from "@effect/vitest"; + +import { mintInviteCode } from "../testing/mint-invite"; + +// Real Better Auth path with Google sign-in configured: set the provider env +// (like the secret + bootstrap admin) before importing, so `loadConfig` sees a +// fully-configured instance when the app graph boots. +process.env.EXECUTOR_DATA_DIR = mkdtempSync(join(tmpdir(), "eh-google-")); +process.env.BETTER_AUTH_SECRET = "test-secret-0123456789-abcdefghijklmnop-qrstuv"; +process.env.EXECUTOR_BOOTSTRAP_ADMIN_EMAIL = "admin@test.local"; +process.env.EXECUTOR_BOOTSTRAP_ADMIN_PASSWORD = "admin-password-123"; +process.env.EXECUTOR_GOOGLE_CLIENT_ID = "test-client-id.apps.googleusercontent.com"; +process.env.EXECUTOR_GOOGLE_CLIENT_SECRET = "test-client-secret"; +process.env.EXECUTOR_GOOGLE_ALLOWED_DOMAINS = "Example.com, @second.example ,"; + +const { loadConfig } = await import("../config"); +const { emailDomain } = await import("./better-auth"); +const { makeSelfHostApiHandler } = await import("../app"); + +const { handler, dispose } = await makeSelfHostApiHandler(); +afterAll(() => dispose()); + +const BASE = "http://localhost:4788"; + +// Run a block with the Google env vars swapped out, restoring them afterwards +// so the booted instance's request-time config reads stay consistent. +const withGoogleEnv = (overrides: Record, run: () => T): T => { + const keys = [ + "EXECUTOR_GOOGLE_CLIENT_ID", + "EXECUTOR_GOOGLE_CLIENT_SECRET", + "EXECUTOR_GOOGLE_ALLOWED_DOMAINS", + ] as const; + const saved = Object.fromEntries(keys.map((k) => [k, process.env[k]])); + for (const key of keys) { + const value = overrides[key]; + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: env save/restore around config reads must restore on assertion failure + try { + return run(); + } finally { + for (const key of keys) { + const value = saved[key]; + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + } +}; + +describe("googleSso config resolution", () => { + it("normalizes the domain allowlist (trim, lowercase, strip @, drop empties)", () => { + const sso = loadConfig().googleSso; + expect(sso).toBeDefined(); + expect(sso!.allowedDomains).toEqual(["example.com", "second.example"]); + }); + + it("is undefined when no Google env is set", () => { + withGoogleEnv({}, () => { + expect(loadConfig().googleSso).toBeUndefined(); + }); + }); + + it("refuses half-configured credentials", () => { + withGoogleEnv({ EXECUTOR_GOOGLE_CLIENT_ID: "id-only" }, () => { + expect(() => loadConfig()).toThrow(/must be set together/); + }); + }); + + it("refuses a configured provider without a domain allowlist", () => { + withGoogleEnv( + { + EXECUTOR_GOOGLE_CLIENT_ID: "id", + EXECUTOR_GOOGLE_CLIENT_SECRET: "secret", + }, + () => { + expect(() => loadConfig()).toThrow(/EXECUTOR_GOOGLE_ALLOWED_DOMAINS/); + }, + ); + }); +}); + +describe("emailDomain", () => { + it("extracts the lowercased domain of a well-formed address", () => { + expect(emailDomain("User@Example.COM")).toBe("example.com"); + }); + + it("returns null for malformed addresses instead of a matchable domain", () => { + expect(emailDomain("@example.com")).toBeNull(); + expect(emailDomain("user@")).toBeNull(); + expect(emailDomain("no-at-sign")).toBeNull(); + }); +}); + +test("auth-config advertises the configured provider (ids only)", async () => { + const res = await handler(new Request(`${BASE}/api/auth-config`)); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ socialProviders: ["google"] }); +}); + +test("social sign-in redirects to the configured Google client", async () => { + const res = await handler( + new Request(`${BASE}/api/auth/sign-in/social`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ provider: "google", callbackURL: "/" }), + }), + ); + expect(res.status).toBe(200); + const body = (await res.json()) as { url?: string }; + expect(body.url).toBeTruthy(); + const url = new URL(body.url!); + expect(url.hostname).toBe("accounts.google.com"); + expect(url.searchParams.get("client_id")).toBe("test-client-id.apps.googleusercontent.com"); + expect(url.searchParams.get("redirect_uri")).toBe(`${BASE}/api/auth/callback/google`); +}); + +test("invite-gated email signup still works with a social provider configured", async () => { + const inviteCode = await mintInviteCode(handler); + const signUp = await handler( + new Request(`${BASE}/api/auth/sign-up/email`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + email: "member@test.local", + password: "member-password-123", + name: "Member", + inviteCode, + }), + }), + ); + expect(signUp.status).toBe(200); +}); + +test("email signup without an invite is still refused with a social provider configured", async () => { + const signUp = await handler( + new Request(`${BASE}/api/auth/sign-up/email`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + email: "stranger@example.com", + password: "stranger-password-123", + name: "Stranger", + }), + }), + ); + expect(signUp.status).toBe(403); +}); diff --git a/apps/host-selfhost/src/config.ts b/apps/host-selfhost/src/config.ts index dae13a5e16..8799c03ac2 100644 --- a/apps/host-selfhost/src/config.ts +++ b/apps/host-selfhost/src/config.ts @@ -18,6 +18,19 @@ import { export const SELF_HOST_NAMESPACE = "executor_selfhost"; export const SELF_HOST_SCHEMA_VERSION = "1.0.0"; +/** + * Google sign-in for the self-host login page and the MCP OAuth connect flow. + * Present only when the operator configured a Google OAuth client; the + * allowlist is what replaces the invite code for social sign-ups (the domain + * IS the invite), so it is required whenever the provider is enabled. + */ +export interface GoogleSsoConfig { + readonly clientId: string; + readonly clientSecret: string; + /** Lowercased email domains admitted without an invite code. */ + readonly allowedDomains: readonly string[]; +} + export interface SelfHostConfig { /** Bind address. Defaults to loopback. */ readonly host: string; @@ -51,6 +64,8 @@ export interface SelfHostConfig { * minutes (the same pattern as MCP_PAUSED_SESSION_IDLE_TIMEOUT_MS on cloud). */ readonly sandboxTimeoutMs: number | undefined; + /** Google sign-in, or undefined when the operator hasn't configured it. */ + readonly googleSso: GoogleSsoConfig | undefined; } export const resolveDataDir = (): string => @@ -157,9 +172,39 @@ export const loadConfig = (): SelfHostConfig => { organizationName: process.env.EXECUTOR_ORG_NAME ?? "Default", orgSlug: resolveOrgSlug(), sandboxTimeoutMs: resolveSandboxTimeoutMs(), + googleSso: resolveGoogleSso(), }; }; +// Half-configured SSO is refused rather than silently ignored (same posture as +// resolveSandboxTimeoutMs): an operator who set one of the two credentials +// should find out at boot, not by staring at a login page with no button. An +// empty domain allowlist is refused too — without it, Google sign-in would be +// open registration for anyone with a Google account, bypassing the invite +// gate entirely. +const resolveGoogleSso = (): GoogleSsoConfig | undefined => { + const clientId = process.env.EXECUTOR_GOOGLE_CLIENT_ID?.trim(); + const clientSecret = process.env.EXECUTOR_GOOGLE_CLIENT_SECRET?.trim(); + if (!clientId && !clientSecret) return undefined; + if (!clientId || !clientSecret) { + // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: refuse to boot on half-configured SSO credentials + throw new Error( + "EXECUTOR_GOOGLE_CLIENT_ID and EXECUTOR_GOOGLE_CLIENT_SECRET must be set together", + ); + } + const allowedDomains = (process.env.EXECUTOR_GOOGLE_ALLOWED_DOMAINS ?? "") + .split(",") + .map((domain) => domain.trim().replace(/^@/, "").toLowerCase()) + .filter((domain) => domain.length > 0); + if (allowedDomains.length === 0) { + // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: Google sign-in without a domain allowlist is open registration; refuse to boot + throw new Error( + 'EXECUTOR_GOOGLE_ALLOWED_DOMAINS is required when Google sign-in is configured (comma-separated email domains, e.g. "example.com") — it is what gates sign-ups in place of an invite code', + ); + } + return { clientId, clientSecret, allowedDomains }; +}; + // A malformed value is refused rather than silently ignored: an operator who // sets the knob and typos it should find out at boot, not by watching a // runaway execution use the 5-minute default. diff --git a/apps/host-selfhost/src/system/api.ts b/apps/host-selfhost/src/system/api.ts index 4b6200dc8d..b5e5c27c53 100644 --- a/apps/host-selfhost/src/system/api.ts +++ b/apps/host-selfhost/src/system/api.ts @@ -7,8 +7,10 @@ import { Schema } from "effect"; // GET /api/health readiness probe (used by the container healthcheck) // GET /api/setup-status whether the instance still needs first-run setup, so // the pre-login SPA can route a fresh operator to /setup +// GET /api/auth-config which social sign-in providers are configured, so +// the login page knows which provider buttons to render // -// Both are deliberately unauthenticated and return only booleans/status — no +// All are deliberately unauthenticated and return only booleans/status — no // sensitive data — so they can be read before anyone has signed in. // --------------------------------------------------------------------------- @@ -21,6 +23,10 @@ export class SystemError extends Schema.TaggedErrorClass()( export const HealthResponse = Schema.Struct({ status: Schema.String }); export const SetupStatusResponse = Schema.Struct({ needsSetup: Schema.Boolean }); export const InviteStatusResponse = Schema.Struct({ valid: Schema.Boolean }); +// Provider ids only (e.g. "google") — never credentials or allowlists. +export const AuthConfigResponse = Schema.Struct({ + socialProviders: Schema.Array(Schema.String), +}); const InviteStatusParams = { code: Schema.String }; @@ -37,6 +43,12 @@ export const SystemApi = HttpApiGroup.make("system") error: [SystemError], }), ) + .add( + HttpApiEndpoint.get("authConfig", "/auth-config", { + success: AuthConfigResponse, + error: [SystemError], + }), + ) .add( HttpApiEndpoint.get("inviteStatus", "/invite-status/:code", { params: InviteStatusParams, diff --git a/apps/host-selfhost/src/system/handlers.ts b/apps/host-selfhost/src/system/handlers.ts index 8ecb4199b8..a81b765bda 100644 --- a/apps/host-selfhost/src/system/handlers.ts +++ b/apps/host-selfhost/src/system/handlers.ts @@ -6,6 +6,7 @@ import { SystemError, SystemHttpApi } from "./api"; import { BetterAuth, countOrgMembers, type BetterAuthHandle } from "../auth/better-auth"; import { SelfHostDb, type SelfHostDbHandle } from "../db/self-host-db"; import { findRedeemableCode } from "../auth/invites"; +import { loadConfig } from "../config"; // --------------------------------------------------------------------------- // Handlers for the public system API. Unauthenticated; every DB touch is an @@ -40,6 +41,14 @@ export const SystemHandlers = HttpApiBuilder.group(SystemHttpApi, "system", (han return { needsSetup: count === 0 }; }), ) + .handle("authConfig", () => + // Which social providers the operator configured — provider ids only, so + // the pre-login page knows which buttons to render. Config is env-derived + // and boot-validated, so this read cannot fail. + Effect.sync(() => ({ + socialProviders: loadConfig().googleSso ? ["google"] : [], + })), + ) .handle("inviteStatus", ({ params }) => Effect.gen(function* () { const { client } = yield* SelfHostDb; diff --git a/apps/host-selfhost/web/auth-config.ts b/apps/host-selfhost/web/auth-config.ts new file mode 100644 index 0000000000..da030a1a87 --- /dev/null +++ b/apps/host-selfhost/web/auth-config.ts @@ -0,0 +1,20 @@ +// Pre-login read of which social sign-in providers the operator configured, so +// the login page knows which provider buttons to render. Same boundary as +// setup-status: a plain same-origin fetch that runs before the atom registry +// exists. Fails soft to "no providers" — the email/password form is always +// available, so a hiccup here degrades to the baseline login, never a lockout. + +export const fetchSocialProviders = async (): Promise => { + const response = await fetch("/api/auth-config", { credentials: "same-origin" }).then( + (r) => r, + () => null, + ); + if (!response?.ok) return []; + const data = (await response.json().then( + (d) => d, + () => ({}), + )) as { socialProviders?: unknown }; + return Array.isArray(data.socialProviders) + ? data.socialProviders.filter((p): p is string => typeof p === "string") + : []; +}; diff --git a/apps/host-selfhost/web/login.tsx b/apps/host-selfhost/web/login.tsx index 94ee3411b8..8fc1fd2bc0 100644 --- a/apps/host-selfhost/web/login.tsx +++ b/apps/host-selfhost/web/login.tsx @@ -1,17 +1,19 @@ -import { useState, type FormEvent } from "react"; +import { useEffect, useState, type FormEvent } from "react"; import { Button } from "@executor-js/react/components/button"; import { Input } from "@executor-js/react/components/input"; import { Label } from "@executor-js/react/components/label"; import { authClient } from "./auth-client"; +import { fetchSocialProviders } from "./auth-config"; import { AuthLayout } from "./auth-layout"; import { postLoginTarget } from "../src/auth/return-to"; -// Self-host login: email + password sign-in via Better Auth. On success we -// reload so the shared AuthProvider re-reads /account/me and the AuthGate swaps -// in the app. (Cloud's equivalent is a WorkOS redirect — this is the -// provider-specific piece injected into the shared shell.) +// Self-host login: email + password sign-in via Better Auth, plus a provider +// button per configured social provider (read from /api/auth-config). On +// success we reload so the shared AuthProvider re-reads /account/me and the +// AuthGate swaps in the app. (Cloud's equivalent is a WorkOS redirect — this is +// the provider-specific piece injected into the shared shell.) // // There is no self-signup here: open registration is closed. New people join by // redeeming an invite — either the full /join/ link, or by entering the @@ -27,6 +29,17 @@ export const LoginPage = () => { const [code, setCode] = useState(""); const [error, setError] = useState(null); const [busy, setBusy] = useState(false); + const [socialProviders, setSocialProviders] = useState([]); + + useEffect(() => { + let cancelled = false; + void fetchSocialProviders().then((providers) => { + if (!cancelled) setSocialProviders(providers); + }); + return () => { + cancelled = true; + }; + }, []); const signIn = async (event: FormEvent) => { event.preventDefault(); @@ -41,6 +54,22 @@ export const LoginPage = () => { window.location.href = postLogin; }; + const signInWithGoogle = async () => { + setBusy(true); + setError(null); + // Better Auth redirects the browser to Google; on callback it lands on + // `callbackURL`, so the deep-link target survives the round trip the same + // way it does for the email form's post-login navigation. + const result = await authClient.signIn.social({ + provider: "google", + callbackURL: postLogin, + }); + if (result.error) { + setBusy(false); + setError(result.error.message ?? "Sign in failed"); + } + }; + const redeem = (event: FormEvent) => { event.preventDefault(); const trimmed = code.trim(); @@ -94,6 +123,24 @@ export const LoginPage = () => { + {socialProviders.includes("google") && ( + <> +
+
+ or +
+
+ + + )} ) : (
From 886469c4321f024a141ec39c46d7805bd73d0bb0 Mon Sep 17 00:00:00 2001 From: Charlie Date: Fri, 28 Aug 2026 07:53:17 +0000 Subject: [PATCH 2/3] feat(host-selfhost): generic OIDC SSO sign-in with verified-domain admission --- .changeset/selfhost-google-sign-in.md | 9 - .changeset/selfhost-sso-sign-in.md | 9 + apps/host-selfhost/package.json | 1 + apps/host-selfhost/src/auth/better-auth.ts | 99 ++++-- .../host-selfhost/src/auth/google-sso.test.ts | 152 --------- apps/host-selfhost/src/auth/sso.test.ts | 313 ++++++++++++++++++ apps/host-selfhost/src/config.ts | 78 +++-- apps/host-selfhost/src/system/api.ts | 8 +- apps/host-selfhost/src/system/handlers.ts | 11 +- apps/host-selfhost/web/auth-client.ts | 3 + apps/host-selfhost/web/auth-config.ts | 29 +- apps/host-selfhost/web/login.tsx | 41 +-- bun.lock | 61 +--- 13 files changed, 499 insertions(+), 315 deletions(-) delete mode 100644 .changeset/selfhost-google-sign-in.md create mode 100644 .changeset/selfhost-sso-sign-in.md delete mode 100644 apps/host-selfhost/src/auth/google-sso.test.ts create mode 100644 apps/host-selfhost/src/auth/sso.test.ts diff --git a/.changeset/selfhost-google-sign-in.md b/.changeset/selfhost-google-sign-in.md deleted file mode 100644 index 40f09decd3..0000000000 --- a/.changeset/selfhost-google-sign-in.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -"executor": patch ---- - -**Self-host: bring-your-own Google sign-in with a domain allowlist** - -Operators can enable Google as a login provider on a self-hosted instance by setting `EXECUTOR_GOOGLE_CLIENT_ID`, `EXECUTOR_GOOGLE_CLIENT_SECRET`, and `EXECUTOR_GOOGLE_ALLOWED_DOMAINS` (comma-separated email domains). The login page renders a "Continue with Google" button when the provider is configured (discovered through the new unauthenticated `GET /api/auth-config`, which returns provider ids 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 social sign-ups: a Google sign-in whose email domain is on the list auto-joins the instance organization as a member, and any other domain is refused. Enabling the provider without an allowlist is refused at boot, as is a half-configured client id/secret pair, so Google sign-in can never silently become open registration. Email/password sign-in and invite-based signup are unchanged. diff --git a/.changeset/selfhost-sso-sign-in.md b/.changeset/selfhost-sso-sign-in.md new file mode 100644 index 0000000000..6cecc2268c --- /dev/null +++ b/.changeset/selfhost-sso-sign-in.md @@ -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 " 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`. diff --git a/apps/host-selfhost/package.json b/apps/host-selfhost/package.json index 5d7fd72fed..8d1cc7fb82 100644 --- a/apps/host-selfhost/package.json +++ b/apps/host-selfhost/package.json @@ -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", diff --git a/apps/host-selfhost/src/auth/better-auth.ts b/apps/host-selfhost/src/auth/better-auth.ts index 46b87ec690..b4fee52589 100644 --- a/apps/host-selfhost/src/auth/better-auth.ts +++ b/apps/host-selfhost/src/auth/better-auth.ts @@ -1,12 +1,19 @@ 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"; import { Context } from "effect"; -import { loadConfig, type GoogleSsoConfig } from "../config"; +import { loadConfig, type SsoConfig } from "../config"; import { seedOrgAndAdmin } from "./seed"; import { consumeInviteCode, ensureInviteCodeTable, findRedeemableCode } from "./invites"; @@ -25,13 +32,14 @@ interface SignupGate { // creation (the seed, or a future admin "add user") flows through other paths. const SIGNUP_PATH = "/sign-up/email"; -// Better Auth serves the social OAuth callback at `/callback/:providerId` -// (`api/routes/callback` in the installed build) — the only path a -// social-provider user creation arrives on. Server-side creation (the seed, -// admin add-user) never carries it, so it cleanly splits "a stranger signed in -// with Google" from every trusted path. -const isSocialCallback = (path: string | undefined): boolean => - path?.startsWith("/callback/") === true; +// Better Auth serves OAuth sign-in callbacks at `/oauth2/callback/:providerId` +// (the genericOAuth plugin, which carries the configured SSO provider) and +// `/callback/:providerId` (built-in social providers) — the only paths an +// IdP-initiated user creation arrives on. Server-side creation (the seed, +// admin add-user) never carries either, so this cleanly splits "a stranger +// signed in at the IdP" from every trusted path. +const isOAuthCallback = (path: string | undefined): boolean => + path?.startsWith("/oauth2/callback/") === true || path?.startsWith("/callback/") === true; // The domain of a well-formed address, or null — so a malformed email can never // match an allowlist entry (`emailDomain("@example.com")` is null, not "example.com"). @@ -41,11 +49,34 @@ export const emailDomain = (email: string): string | null => { return email.slice(at + 1).toLowerCase(); }; -const isDomainAdmitted = (sso: GoogleSsoConfig, email: string): boolean => { - const domain = emailDomain(email); +// Admission = the IdP vouches for the address (`email_verified`, mapped to +// `emailVerified` by the genericOAuth callback) AND its domain is allowlisted. +// The verified check is load-bearing: an unverified claim is whatever the +// account holder typed, so without it anyone could register an IdP account +// with a made-up allowlisted address and walk in. +const isAdmitted = (sso: SsoConfig, user: { email: string; emailVerified: boolean }): boolean => { + if (!user.emailVerified) return false; + const domain = emailDomain(user.email); return domain !== null && sso.allowedDomains.includes(domain); }; +// The genericOAuth registration for the configured provider. Everything is +// derived from the IdP's discovery document; PKCE is on unconditionally (any +// OIDC-compliant IdP supports it). For Google with a single allowed domain, +// `hd` pre-filters the account chooser to that Workspace domain — a UX hint +// only (Google treats it as advisory); the create-hook gate is the enforcement. +const ssoProviderConfig = (sso: SsoConfig) => ({ + providerId: sso.providerId, + clientId: sso.clientId, + clientSecret: sso.clientSecret, + discoveryUrl: sso.discoveryUrl, + scopes: ["openid", "email", "profile"], + pkce: true, + ...(sso.providerId === "google" && sso.allowedDomains.length === 1 + ? { authorizationUrlParams: { hd: sso.allowedDomains[0]! } } + : {}), +}); + // --------------------------------------------------------------------------- // Better Auth instance over the SAME libSQL CONNECTION as the FumaDB executor // tables ("one connection, two schema regions"). @@ -120,19 +151,6 @@ const makeAuthOptions = (client: Client, getOrganizationId: () => string, gate?: baseURL: config.webBaseUrl, trustedOrigins: [config.webBaseUrl], emailAndPassword: { enabled: true }, - // Google sign-in, when the operator configured it (see config.ts). The - // domain gate below is what admits or refuses the users this creates — - // enabling the provider alone never opens registration. - ...(config.googleSso - ? { - socialProviders: { - google: { - clientId: config.googleSso.clientId, - clientSecret: config.googleSso.clientSecret, - }, - }, - } - : {}), // `apiKey` issues long-lived personal keys (the API-keys page). With // `enableSessionForAPIKeys`, presenting a key resolves to its owner's // session — so a key works as a Bearer token for the API + MCP endpoint. @@ -175,6 +193,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 @@ -211,19 +237,20 @@ const makeAuthOptions = (client: Client, getOrganizationId: () => string, gate?: create: { before: async (user, context) => { if (context?.path !== SIGNUP_PATH) { - // Social sign-ups arrive on the OAuth callback path; the - // domain allowlist gates them in place of an invite code. - // Server-side creation (the seed, admin add-user) passes. - const sso = config.googleSso; + // 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 ( - isSocialCallback(context?.path) && - !(sso !== undefined && isDomainAdmitted(sso, user.email)) + 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 ${sso.allowedDomains.map((d) => `@${d}`).join(", ")} accounts.` - : "Social sign-up is not enabled on this instance.", + ? `Sign-ups are restricted to verified ${sso.allowedDomains.map((d) => `@${d}`).join(", ")} accounts.` + : "SSO sign-up is not enabled on this instance.", }); } return; @@ -247,16 +274,16 @@ const makeAuthOptions = (client: Client, getOrganizationId: () => string, gate?: const auth = gate.getAuth(); if (!auth) return; if (context?.path !== SIGNUP_PATH) { - // A social user that reached `after` was domain-admitted by + // 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.googleSso; + const sso = config.sso; if ( - isSocialCallback(context?.path) && + isOAuthCallback(context?.path) && sso !== undefined && - isDomainAdmitted(sso, user.email) + isAdmitted(sso, user) ) { await auth.api.addMember({ body: { diff --git a/apps/host-selfhost/src/auth/google-sso.test.ts b/apps/host-selfhost/src/auth/google-sso.test.ts deleted file mode 100644 index 95e387a1cf..0000000000 --- a/apps/host-selfhost/src/auth/google-sso.test.ts +++ /dev/null @@ -1,152 +0,0 @@ -import { mkdtempSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; - -import { afterAll, describe, expect, it, test } from "@effect/vitest"; - -import { mintInviteCode } from "../testing/mint-invite"; - -// Real Better Auth path with Google sign-in configured: set the provider env -// (like the secret + bootstrap admin) before importing, so `loadConfig` sees a -// fully-configured instance when the app graph boots. -process.env.EXECUTOR_DATA_DIR = mkdtempSync(join(tmpdir(), "eh-google-")); -process.env.BETTER_AUTH_SECRET = "test-secret-0123456789-abcdefghijklmnop-qrstuv"; -process.env.EXECUTOR_BOOTSTRAP_ADMIN_EMAIL = "admin@test.local"; -process.env.EXECUTOR_BOOTSTRAP_ADMIN_PASSWORD = "admin-password-123"; -process.env.EXECUTOR_GOOGLE_CLIENT_ID = "test-client-id.apps.googleusercontent.com"; -process.env.EXECUTOR_GOOGLE_CLIENT_SECRET = "test-client-secret"; -process.env.EXECUTOR_GOOGLE_ALLOWED_DOMAINS = "Example.com, @second.example ,"; - -const { loadConfig } = await import("../config"); -const { emailDomain } = await import("./better-auth"); -const { makeSelfHostApiHandler } = await import("../app"); - -const { handler, dispose } = await makeSelfHostApiHandler(); -afterAll(() => dispose()); - -const BASE = "http://localhost:4788"; - -// Run a block with the Google env vars swapped out, restoring them afterwards -// so the booted instance's request-time config reads stay consistent. -const withGoogleEnv = (overrides: Record, run: () => T): T => { - const keys = [ - "EXECUTOR_GOOGLE_CLIENT_ID", - "EXECUTOR_GOOGLE_CLIENT_SECRET", - "EXECUTOR_GOOGLE_ALLOWED_DOMAINS", - ] as const; - const saved = Object.fromEntries(keys.map((k) => [k, process.env[k]])); - for (const key of keys) { - const value = overrides[key]; - if (value === undefined) delete process.env[key]; - else process.env[key] = value; - } - // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: env save/restore around config reads must restore on assertion failure - try { - return run(); - } finally { - for (const key of keys) { - const value = saved[key]; - if (value === undefined) delete process.env[key]; - else process.env[key] = value; - } - } -}; - -describe("googleSso config resolution", () => { - it("normalizes the domain allowlist (trim, lowercase, strip @, drop empties)", () => { - const sso = loadConfig().googleSso; - expect(sso).toBeDefined(); - expect(sso!.allowedDomains).toEqual(["example.com", "second.example"]); - }); - - it("is undefined when no Google env is set", () => { - withGoogleEnv({}, () => { - expect(loadConfig().googleSso).toBeUndefined(); - }); - }); - - it("refuses half-configured credentials", () => { - withGoogleEnv({ EXECUTOR_GOOGLE_CLIENT_ID: "id-only" }, () => { - expect(() => loadConfig()).toThrow(/must be set together/); - }); - }); - - it("refuses a configured provider without a domain allowlist", () => { - withGoogleEnv( - { - EXECUTOR_GOOGLE_CLIENT_ID: "id", - EXECUTOR_GOOGLE_CLIENT_SECRET: "secret", - }, - () => { - expect(() => loadConfig()).toThrow(/EXECUTOR_GOOGLE_ALLOWED_DOMAINS/); - }, - ); - }); -}); - -describe("emailDomain", () => { - it("extracts the lowercased domain of a well-formed address", () => { - expect(emailDomain("User@Example.COM")).toBe("example.com"); - }); - - it("returns null for malformed addresses instead of a matchable domain", () => { - expect(emailDomain("@example.com")).toBeNull(); - expect(emailDomain("user@")).toBeNull(); - expect(emailDomain("no-at-sign")).toBeNull(); - }); -}); - -test("auth-config advertises the configured provider (ids only)", async () => { - const res = await handler(new Request(`${BASE}/api/auth-config`)); - expect(res.status).toBe(200); - expect(await res.json()).toEqual({ socialProviders: ["google"] }); -}); - -test("social sign-in redirects to the configured Google client", async () => { - const res = await handler( - new Request(`${BASE}/api/auth/sign-in/social`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ provider: "google", callbackURL: "/" }), - }), - ); - expect(res.status).toBe(200); - const body = (await res.json()) as { url?: string }; - expect(body.url).toBeTruthy(); - const url = new URL(body.url!); - expect(url.hostname).toBe("accounts.google.com"); - expect(url.searchParams.get("client_id")).toBe("test-client-id.apps.googleusercontent.com"); - expect(url.searchParams.get("redirect_uri")).toBe(`${BASE}/api/auth/callback/google`); -}); - -test("invite-gated email signup still works with a social provider configured", async () => { - const inviteCode = await mintInviteCode(handler); - const signUp = await handler( - new Request(`${BASE}/api/auth/sign-up/email`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ - email: "member@test.local", - password: "member-password-123", - name: "Member", - inviteCode, - }), - }), - ); - expect(signUp.status).toBe(200); -}); - -test("email signup without an invite is still refused with a social provider configured", async () => { - const signUp = await handler( - new Request(`${BASE}/api/auth/sign-up/email`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ - email: "stranger@example.com", - password: "stranger-password-123", - name: "Stranger", - }), - }), - ); - expect(signUp.status).toBe(403); -}); diff --git a/apps/host-selfhost/src/auth/sso.test.ts b/apps/host-selfhost/src/auth/sso.test.ts new file mode 100644 index 0000000000..a1ed18a0d5 --- /dev/null +++ b/apps/host-selfhost/src/auth/sso.test.ts @@ -0,0 +1,313 @@ +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { afterAll, describe, expect, it, test } from "@effect/vitest"; +import { createEmulator } from "@executor-js/emulate"; + +import { mintInviteCode } from "../testing/mint-invite"; + +// Real Better Auth path with an SSO provider configured — and a REAL IdP on the +// wire: an @executor-js/emulate Okta instance serving OIDC discovery, +// authorize, and token, so the round-trip tests below exercise the same +// discovery -> redirect -> consent -> callback flow a production Google/Okta +// deployment does. Emulator + registered client + env must all exist before +// the app import, so `loadConfig` sees a fully-configured instance at boot. +const BASE = "http://localhost:4788"; +const CALLBACK = `${BASE}/api/auth/oauth2/callback/okta`; + +const idp = await createEmulator({ service: "okta", port: 4790 }); +afterAll(() => idp.close()); + +const registration = await fetch(`${idp.url}/oauth2/v1/clients`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + client_name: "executor-selfhost-test", + redirect_uris: [CALLBACK], + grant_types: ["authorization_code"], + response_types: ["code"], + token_endpoint_auth_method: "client_secret_post", + }), +}).then((r) => r.json() as Promise<{ client_id: string; client_secret: string }>); + +const createIdpUser = (email: string, firstName: string) => + fetch(`${idp.url}/api/v1/users`, { + method: "POST", + headers: { "content-type": "application/json", authorization: "SSWS test" }, + body: JSON.stringify({ profile: { login: email, email, firstName, lastName: "User" } }), + }); +await createIdpUser("alice@example.com", "Alice"); +await createIdpUser("mallory@evil.test", "Mallory"); + +process.env.EXECUTOR_DATA_DIR = mkdtempSync(join(tmpdir(), "eh-sso-")); +process.env.BETTER_AUTH_SECRET = "test-secret-0123456789-abcdefghijklmnop-qrstuv"; +process.env.EXECUTOR_BOOTSTRAP_ADMIN_EMAIL = "admin@test.local"; +process.env.EXECUTOR_BOOTSTRAP_ADMIN_PASSWORD = "admin-password-123"; +process.env.EXECUTOR_SSO_PROVIDER_ID = "okta"; +process.env.EXECUTOR_SSO_DISCOVERY_URL = `${idp.url}/.well-known/openid-configuration`; +process.env.EXECUTOR_SSO_CLIENT_ID = registration.client_id; +process.env.EXECUTOR_SSO_CLIENT_SECRET = registration.client_secret; +process.env.EXECUTOR_SSO_ALLOWED_DOMAINS = "Example.com, @second.example ,"; + +const { loadConfig } = await import("../config"); +const { emailDomain } = await import("./better-auth"); +const { makeSelfHostApiHandler } = await import("../app"); + +const { handler, dispose } = await makeSelfHostApiHandler(); +afterAll(() => dispose()); + +const SSO_ENV_KEYS = [ + "EXECUTOR_SSO_PROVIDER_ID", + "EXECUTOR_SSO_PROVIDER_NAME", + "EXECUTOR_SSO_DISCOVERY_URL", + "EXECUTOR_SSO_CLIENT_ID", + "EXECUTOR_SSO_CLIENT_SECRET", + "EXECUTOR_SSO_ALLOWED_DOMAINS", +] as const; + +// Run a block with the SSO env vars swapped out, restoring them afterwards so +// the booted instance's request-time config reads stay consistent. +const withSsoEnv = (overrides: Record, run: () => T): T => { + const saved = Object.fromEntries(SSO_ENV_KEYS.map((k) => [k, process.env[k]])); + for (const key of SSO_ENV_KEYS) { + const value = overrides[key]; + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: env save/restore around config reads must restore on assertion failure + try { + return run(); + } finally { + for (const key of SSO_ENV_KEYS) { + const value = saved[key]; + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + } +}; + +describe("sso config resolution", () => { + it("normalizes the domain allowlist (trim, lowercase, strip @, drop empties)", () => { + const sso = loadConfig().sso; + expect(sso).toBeDefined(); + expect(sso!.allowedDomains).toEqual(["example.com", "second.example"]); + }); + + it("derives the display name from the provider id when not set", () => { + expect(loadConfig().sso!.providerName).toBe("Okta"); + }); + + it("is undefined when no SSO env is set", () => { + withSsoEnv({}, () => { + expect(loadConfig().sso).toBeUndefined(); + }); + }); + + it("refuses half-configured credentials", () => { + withSsoEnv({ EXECUTOR_SSO_CLIENT_ID: "id-only" }, () => { + expect(() => loadConfig()).toThrow(/must be set together/); + }); + }); + + it("refuses credentials without a provider id", () => { + withSsoEnv({ EXECUTOR_SSO_CLIENT_ID: "id", EXECUTOR_SSO_CLIENT_SECRET: "secret" }, () => { + expect(() => loadConfig()).toThrow(/EXECUTOR_SSO_PROVIDER_ID/); + }); + }); + + it("presets Google's discovery document so only credentials + domains are needed", () => { + withSsoEnv( + { + EXECUTOR_SSO_PROVIDER_ID: "google", + EXECUTOR_SSO_CLIENT_ID: "id.apps.googleusercontent.com", + EXECUTOR_SSO_CLIENT_SECRET: "secret", + EXECUTOR_SSO_ALLOWED_DOMAINS: "example.com", + }, + () => { + const sso = loadConfig().sso; + expect(sso!.discoveryUrl).toBe( + "https://accounts.google.com/.well-known/openid-configuration", + ); + expect(sso!.providerName).toBe("Google"); + }, + ); + }); + + it("refuses a provider without a known or explicit discovery URL", () => { + withSsoEnv( + { + EXECUTOR_SSO_PROVIDER_ID: "okta", + EXECUTOR_SSO_CLIENT_ID: "id", + EXECUTOR_SSO_CLIENT_SECRET: "secret", + EXECUTOR_SSO_ALLOWED_DOMAINS: "example.com", + }, + () => { + expect(() => loadConfig()).toThrow(/EXECUTOR_SSO_DISCOVERY_URL/); + }, + ); + }); + + it("refuses a configured provider without a domain allowlist", () => { + withSsoEnv( + { + EXECUTOR_SSO_PROVIDER_ID: "okta", + EXECUTOR_SSO_DISCOVERY_URL: "https://idp.example/.well-known/openid-configuration", + EXECUTOR_SSO_CLIENT_ID: "id", + EXECUTOR_SSO_CLIENT_SECRET: "secret", + }, + () => { + expect(() => loadConfig()).toThrow(/EXECUTOR_SSO_ALLOWED_DOMAINS/); + }, + ); + }); +}); + +describe("emailDomain", () => { + it("extracts the lowercased domain of a well-formed address", () => { + expect(emailDomain("User@Example.COM")).toBe("example.com"); + }); + + it("returns null for malformed addresses instead of a matchable domain", () => { + expect(emailDomain("@example.com")).toBeNull(); + expect(emailDomain("user@")).toBeNull(); + expect(emailDomain("no-at-sign")).toBeNull(); + }); +}); + +test("auth-config advertises the configured provider (id + name only)", async () => { + const res = await handler(new Request(`${BASE}/api/auth-config`)); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ ssoProviders: [{ id: "okta", name: "Okta" }] }); +}); + +// --- The full OIDC round-trip against the emulated IdP ----------------------- + +const decodeHtml = (value: string): string => + value + .replaceAll("&", "&") + .replaceAll(""", '"') + .replaceAll("'", "'") + .replaceAll("<", "<") + .replaceAll(">", ">"); + +const formFields = (form: string): Record => { + const fields: Record = {}; + for (const input of form.matchAll(/]*>/gi)) { + const tag = input[0]; + const name = tag.match(/\bname=["']([^"']+)["']/i)?.[1]; + const value = tag.match(/\bvalue=["']([^"']*)["']/i)?.[1] ?? ""; + if (name) fields[decodeHtml(name)] = decodeHtml(value); + } + return fields; +}; + +// Drive the whole flow as the given IdP account: sign-in redirect -> IdP +// consent page (pick the account's form) -> callback back into the app. +// Returns the callback response plus the cookies it set. +const signInThroughIdp = async (email: string) => { + const start = await handler( + new Request(`${BASE}/api/auth/sign-in/oauth2`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ providerId: "okta", callbackURL: "/" }), + }), + ); + expect(start.status).toBe(200); + const { url } = (await start.json()) as { url: string }; + expect(url.startsWith(idp.url)).toBe(true); + const stateCookies = start.headers + .getSetCookie() + .map((cookie) => cookie.split(";")[0]!) + .join("; "); + + const consentHtml = await fetch(url).then((r) => r.text()); + const form = [...consentHtml.matchAll(//gi)] + .map((m) => m[0]) + .find((f) => f.includes(email)); + expect(form).toBeDefined(); + const action = decodeHtml(form!.match(/\baction=["']([^"']+)["']/i)![1]!); + const consent = await fetch(action, { + method: "POST", + headers: { "content-type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams(formFields(form!)), + redirect: "manual", + }); + expect(consent.status).toBe(302); + const callbackUrl = consent.headers.get("location")!; + expect(callbackUrl.startsWith(CALLBACK)).toBe(true); + + const callback = await handler(new Request(callbackUrl, { headers: { cookie: stateCookies } })); + const cookies = callback.headers + .getSetCookie() + .map((cookie) => cookie.split(";")[0]!) + .join("; "); + return { callback, cookies }; +}; + +test("an allowlisted-domain IdP account signs in and joins the org as a member", async () => { + const { callback, cookies } = await signInThroughIdp("alice@example.com"); + expect(callback.status).toBe(302); + expect(callback.headers.get("location")).toBe("/"); + + const session = await handler( + new Request(`${BASE}/api/auth/get-session`, { headers: { cookie: cookies } }), + ); + expect(session.status).toBe(200); + const sessionBody = (await session.json()) as { user?: { email?: string } }; + expect(sessionBody.user?.email).toBe("alice@example.com"); + + const orgs = await handler( + new Request(`${BASE}/api/auth/organization/list`, { headers: { cookie: cookies } }), + ); + expect(orgs.status).toBe(200); + expect(((await orgs.json()) as unknown[]).length).toBe(1); +}); + +test("an IdP account outside the allowlist is refused and gets no session", async () => { + const { callback, cookies } = await signInThroughIdp("mallory@evil.test"); + // The rejection surfaces as better-auth's error redirect, not a success. + expect(callback.status).toBe(302); + expect(callback.headers.get("location")).not.toBe("/"); + expect(callback.headers.get("location")).toContain("error"); + + const session = await handler( + new Request(`${BASE}/api/auth/get-session`, { headers: { cookie: cookies } }), + ); + const sessionBody = (await session.json()) as { user?: unknown } | null; + expect(sessionBody?.user ?? null).toBeNull(); +}); + +// --- Invite-code regressions with SSO configured ------------------------------ + +test("invite-gated email signup still works with an SSO provider configured", async () => { + const inviteCode = await mintInviteCode(handler); + const signUp = await handler( + new Request(`${BASE}/api/auth/sign-up/email`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + email: "member@test.local", + password: "member-password-123", + name: "Member", + inviteCode, + }), + }), + ); + expect(signUp.status).toBe(200); +}); + +test("email signup without an invite is still refused with an SSO provider configured", async () => { + const signUp = await handler( + new Request(`${BASE}/api/auth/sign-up/email`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + email: "stranger@example.com", + password: "stranger-password-123", + name: "Stranger", + }), + }), + ); + expect(signUp.status).toBe(403); +}); diff --git a/apps/host-selfhost/src/config.ts b/apps/host-selfhost/src/config.ts index 8799c03ac2..7b2caa63d3 100644 --- a/apps/host-selfhost/src/config.ts +++ b/apps/host-selfhost/src/config.ts @@ -19,12 +19,19 @@ export const SELF_HOST_NAMESPACE = "executor_selfhost"; export const SELF_HOST_SCHEMA_VERSION = "1.0.0"; /** - * Google sign-in for the self-host login page and the MCP OAuth connect flow. - * Present only when the operator configured a Google OAuth client; the - * allowlist is what replaces the invite code for social sign-ups (the domain - * IS the invite), so it is required whenever the provider is enabled. + * SSO sign-in for the self-host login page and the MCP OAuth connect flow: one + * OIDC provider (Google, Okta, Entra, any discovery-compliant IdP) resolved + * from the environment. Present only when the operator configured it; the + * allowlist is what replaces the invite code for SSO sign-ups (the domain IS + * the invite), so it is required whenever the provider is enabled. */ -export interface GoogleSsoConfig { +export interface SsoConfig { + /** URL-safe id — also the OAuth callback path segment and the button key. */ + readonly providerId: string; + /** Display name for the login button (“Continue with ”). */ + readonly providerName: string; + /** The IdP's OIDC discovery document (…/.well-known/openid-configuration). */ + readonly discoveryUrl: string; readonly clientId: string; readonly clientSecret: string; /** Lowercased email domains admitted without an invite code. */ @@ -64,8 +71,8 @@ export interface SelfHostConfig { * minutes (the same pattern as MCP_PAUSED_SESSION_IDLE_TIMEOUT_MS on cloud). */ readonly sandboxTimeoutMs: number | undefined; - /** Google sign-in, or undefined when the operator hasn't configured it. */ - readonly googleSso: GoogleSsoConfig | undefined; + /** SSO sign-in, or undefined when the operator hasn't configured it. */ + readonly sso: SsoConfig | undefined; } export const resolveDataDir = (): string => @@ -172,37 +179,64 @@ export const loadConfig = (): SelfHostConfig => { organizationName: process.env.EXECUTOR_ORG_NAME ?? "Default", orgSlug: resolveOrgSlug(), sandboxTimeoutMs: resolveSandboxTimeoutMs(), - googleSso: resolveGoogleSso(), + sso: resolveSso(), }; }; -// Half-configured SSO is refused rather than silently ignored (same posture as -// resolveSandboxTimeoutMs): an operator who set one of the two credentials -// should find out at boot, not by staring at a login page with no button. An -// empty domain allowlist is refused too — without it, Google sign-in would be -// open registration for anyone with a Google account, bypassing the invite -// gate entirely. -const resolveGoogleSso = (): GoogleSsoConfig | undefined => { - const clientId = process.env.EXECUTOR_GOOGLE_CLIENT_ID?.trim(); - const clientSecret = process.env.EXECUTOR_GOOGLE_CLIENT_SECRET?.trim(); +// Well-known discovery documents for providers an operator can name without +// hunting down the URL. Anything else (Okta, Entra, Auth0, …) has a +// tenant-specific issuer, so EXECUTOR_SSO_DISCOVERY_URL is required for it. +const DISCOVERY_PRESETS: Record = { + google: "https://accounts.google.com/.well-known/openid-configuration", +}; + +// The provider id doubles as the OAuth callback path segment +// (`/api/auth/oauth2/callback/`), so it must be URL-safe. +const PROVIDER_ID_PATTERN = /^[a-z0-9-]{1,48}$/; + +// A half-configured provider is refused rather than silently ignored (same +// posture as resolveSandboxTimeoutMs): an operator who set some of the +// variables should find out at boot, not by staring at a login page with no +// button. An empty domain allowlist is refused too — without it, SSO sign-in +// would be open registration for anyone with an account at the IdP, bypassing +// the invite gate entirely. +const resolveSso = (): SsoConfig | undefined => { + const clientId = process.env.EXECUTOR_SSO_CLIENT_ID?.trim(); + const clientSecret = process.env.EXECUTOR_SSO_CLIENT_SECRET?.trim(); if (!clientId && !clientSecret) return undefined; if (!clientId || !clientSecret) { // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: refuse to boot on half-configured SSO credentials + throw new Error("EXECUTOR_SSO_CLIENT_ID and EXECUTOR_SSO_CLIENT_SECRET must be set together"); + } + const providerId = process.env.EXECUTOR_SSO_PROVIDER_ID?.trim().toLowerCase() ?? ""; + if (!PROVIDER_ID_PATTERN.test(providerId)) { + // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: refuse to boot on a missing/malformed provider id + throw new Error( + 'EXECUTOR_SSO_PROVIDER_ID is required when SSO is configured (1-48 chars of [a-z0-9-], e.g. "google" or "okta") — it names the provider and its OAuth callback path', + ); + } + const discoveryUrl = + process.env.EXECUTOR_SSO_DISCOVERY_URL?.trim() || DISCOVERY_PRESETS[providerId]; + if (!discoveryUrl) { + // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: refuse to boot without a way to reach the IdP throw new Error( - "EXECUTOR_GOOGLE_CLIENT_ID and EXECUTOR_GOOGLE_CLIENT_SECRET must be set together", + `EXECUTOR_SSO_DISCOVERY_URL is required for provider ${JSON.stringify(providerId)} (the IdP's …/.well-known/openid-configuration URL)`, ); } - const allowedDomains = (process.env.EXECUTOR_GOOGLE_ALLOWED_DOMAINS ?? "") + const allowedDomains = (process.env.EXECUTOR_SSO_ALLOWED_DOMAINS ?? "") .split(",") .map((domain) => domain.trim().replace(/^@/, "").toLowerCase()) .filter((domain) => domain.length > 0); if (allowedDomains.length === 0) { - // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: Google sign-in without a domain allowlist is open registration; refuse to boot + // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: SSO sign-in without a domain allowlist is open registration; refuse to boot throw new Error( - 'EXECUTOR_GOOGLE_ALLOWED_DOMAINS is required when Google sign-in is configured (comma-separated email domains, e.g. "example.com") — it is what gates sign-ups in place of an invite code', + 'EXECUTOR_SSO_ALLOWED_DOMAINS is required when SSO is configured (comma-separated email domains, e.g. "example.com") — it is what gates sign-ups in place of an invite code', ); } - return { clientId, clientSecret, allowedDomains }; + const providerName = + process.env.EXECUTOR_SSO_PROVIDER_NAME?.trim() || + providerId.charAt(0).toUpperCase() + providerId.slice(1); + return { providerId, providerName, discoveryUrl, clientId, clientSecret, allowedDomains }; }; // A malformed value is refused rather than silently ignored: an operator who diff --git a/apps/host-selfhost/src/system/api.ts b/apps/host-selfhost/src/system/api.ts index b5e5c27c53..01d8424cd3 100644 --- a/apps/host-selfhost/src/system/api.ts +++ b/apps/host-selfhost/src/system/api.ts @@ -7,8 +7,8 @@ import { Schema } from "effect"; // GET /api/health readiness probe (used by the container healthcheck) // GET /api/setup-status whether the instance still needs first-run setup, so // the pre-login SPA can route a fresh operator to /setup -// GET /api/auth-config which social sign-in providers are configured, so -// the login page knows which provider buttons to render +// GET /api/auth-config which SSO sign-in providers are configured, so the +// login page knows which provider buttons to render // // All are deliberately unauthenticated and return only booleans/status — no // sensitive data — so they can be read before anyone has signed in. @@ -23,9 +23,9 @@ export class SystemError extends Schema.TaggedErrorClass()( export const HealthResponse = Schema.Struct({ status: Schema.String }); export const SetupStatusResponse = Schema.Struct({ needsSetup: Schema.Boolean }); export const InviteStatusResponse = Schema.Struct({ valid: Schema.Boolean }); -// Provider ids only (e.g. "google") — never credentials or allowlists. +// Provider ids + display names only — never credentials or allowlists. export const AuthConfigResponse = Schema.Struct({ - socialProviders: Schema.Array(Schema.String), + ssoProviders: Schema.Array(Schema.Struct({ id: Schema.String, name: Schema.String })), }); const InviteStatusParams = { code: Schema.String }; diff --git a/apps/host-selfhost/src/system/handlers.ts b/apps/host-selfhost/src/system/handlers.ts index a81b765bda..ece96d8215 100644 --- a/apps/host-selfhost/src/system/handlers.ts +++ b/apps/host-selfhost/src/system/handlers.ts @@ -42,12 +42,13 @@ export const SystemHandlers = HttpApiBuilder.group(SystemHttpApi, "system", (han }), ) .handle("authConfig", () => - // Which social providers the operator configured — provider ids only, so - // the pre-login page knows which buttons to render. Config is env-derived + // Which SSO provider the operator configured — id + display name only, so + // the pre-login page knows which button to render. Config is env-derived // and boot-validated, so this read cannot fail. - Effect.sync(() => ({ - socialProviders: loadConfig().googleSso ? ["google"] : [], - })), + Effect.sync(() => { + const sso = loadConfig().sso; + return { ssoProviders: sso ? [{ id: sso.providerId, name: sso.providerName }] : [] }; + }), ) .handle("inviteStatus", ({ params }) => Effect.gen(function* () { diff --git a/apps/host-selfhost/web/auth-client.ts b/apps/host-selfhost/web/auth-client.ts index 1a9da926aa..5a1cbfb632 100644 --- a/apps/host-selfhost/web/auth-client.ts +++ b/apps/host-selfhost/web/auth-client.ts @@ -1,9 +1,12 @@ import { createAuthClient } from "better-auth/react"; +import { genericOAuthClient } from "better-auth/client/plugins"; // Better Auth browser client. Talks to the self-host server's /api/auth (same // origin); the session cookie it sets is what the shared AuthProvider's // /account/me query and all API calls authenticate with. Only the login form // and sign-out use this — auth STATE comes from the shared AuthProvider. +// `genericOAuthClient` adds `signIn.oauth2` for the configured SSO provider. export const authClient = createAuthClient({ baseURL: `${window.location.origin}/api/auth`, + plugins: [genericOAuthClient()], }); diff --git a/apps/host-selfhost/web/auth-config.ts b/apps/host-selfhost/web/auth-config.ts index da030a1a87..4fe7549923 100644 --- a/apps/host-selfhost/web/auth-config.ts +++ b/apps/host-selfhost/web/auth-config.ts @@ -1,10 +1,21 @@ -// Pre-login read of which social sign-in providers the operator configured, so -// the login page knows which provider buttons to render. Same boundary as -// setup-status: a plain same-origin fetch that runs before the atom registry -// exists. Fails soft to "no providers" — the email/password form is always -// available, so a hiccup here degrades to the baseline login, never a lockout. +// Pre-login read of which SSO providers the operator configured, so the login +// page knows which provider buttons to render. Same boundary as setup-status: a +// plain same-origin fetch that runs before the atom registry exists. Fails soft +// to "no providers" — the email/password form is always available, so a hiccup +// here degrades to the baseline login, never a lockout. -export const fetchSocialProviders = async (): Promise => { +export interface SsoProvider { + readonly id: string; + readonly name: string; +} + +const isSsoProvider = (value: unknown): value is SsoProvider => + typeof value === "object" && + value !== null && + typeof (value as { id?: unknown }).id === "string" && + typeof (value as { name?: unknown }).name === "string"; + +export const fetchSsoProviders = async (): Promise => { const response = await fetch("/api/auth-config", { credentials: "same-origin" }).then( (r) => r, () => null, @@ -13,8 +24,6 @@ export const fetchSocialProviders = async (): Promise => { const data = (await response.json().then( (d) => d, () => ({}), - )) as { socialProviders?: unknown }; - return Array.isArray(data.socialProviders) - ? data.socialProviders.filter((p): p is string => typeof p === "string") - : []; + )) as { ssoProviders?: unknown }; + return Array.isArray(data.ssoProviders) ? data.ssoProviders.filter(isSsoProvider) : []; }; diff --git a/apps/host-selfhost/web/login.tsx b/apps/host-selfhost/web/login.tsx index 8fc1fd2bc0..09334add6c 100644 --- a/apps/host-selfhost/web/login.tsx +++ b/apps/host-selfhost/web/login.tsx @@ -5,12 +5,12 @@ import { Input } from "@executor-js/react/components/input"; import { Label } from "@executor-js/react/components/label"; import { authClient } from "./auth-client"; -import { fetchSocialProviders } from "./auth-config"; +import { fetchSsoProviders, type SsoProvider } from "./auth-config"; import { AuthLayout } from "./auth-layout"; import { postLoginTarget } from "../src/auth/return-to"; // Self-host login: email + password sign-in via Better Auth, plus a provider -// button per configured social provider (read from /api/auth-config). On +// button per configured SSO provider (read from /api/auth-config). On // success we reload so the shared AuthProvider re-reads /account/me and the // AuthGate swaps in the app. (Cloud's equivalent is a WorkOS redirect — this is // the provider-specific piece injected into the shared shell.) @@ -29,12 +29,12 @@ export const LoginPage = () => { const [code, setCode] = useState(""); const [error, setError] = useState(null); const [busy, setBusy] = useState(false); - const [socialProviders, setSocialProviders] = useState([]); + const [ssoProviders, setSsoProviders] = useState([]); useEffect(() => { let cancelled = false; - void fetchSocialProviders().then((providers) => { - if (!cancelled) setSocialProviders(providers); + void fetchSsoProviders().then((providers) => { + if (!cancelled) setSsoProviders(providers); }); return () => { cancelled = true; @@ -54,14 +54,14 @@ export const LoginPage = () => { window.location.href = postLogin; }; - const signInWithGoogle = async () => { + const signInWithSso = async (providerId: string) => { setBusy(true); setError(null); - // Better Auth redirects the browser to Google; on callback it lands on + // Better Auth redirects the browser to the IdP; on callback it lands on // `callbackURL`, so the deep-link target survives the round trip the same // way it does for the email form's post-login navigation. - const result = await authClient.signIn.social({ - provider: "google", + const result = await authClient.signIn.oauth2({ + providerId, callbackURL: postLogin, }); if (result.error) { @@ -123,22 +123,25 @@ export const LoginPage = () => { - {socialProviders.includes("google") && ( + {ssoProviders.length > 0 && ( <>
or
- + {ssoProviders.map((provider) => ( + + ))} )} diff --git a/bun.lock b/bun.lock index 971d7ee5d5..6495f22887 100644 --- a/bun.lock +++ b/bun.lock @@ -256,6 +256,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", @@ -1390,24 +1391,6 @@ "@aws/lambda-invoke-store": ["@aws/lambda-invoke-store@0.2.4", "", {}, "sha512-iY8yvjE0y651BixKNPgmv1WrQc+GZ142sb0z4gYnChDDY2YqI4P/jsSopBWrKfAt7LOJAkOXt7rC/hms+WclQQ=="], - "@azure/abort-controller": ["@azure/abort-controller@2.1.2", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-nBrLsEWm4J2u5LpAPjxADTlq3trDgVZZXHNKabeXZtpq3d3AbN/KGO82R87rdDz5/lYB024rtEf10/q0urNgsA=="], - - "@azure/core-auth": ["@azure/core-auth@1.10.1", "", { "dependencies": { "@azure/abort-controller": "^2.1.2", "@azure/core-util": "^1.13.0", "tslib": "^2.6.2" } }, "sha512-ykRMW8PjVAn+RS6ww5cmK9U2CyH9p4Q88YJwvUslfuMmN98w/2rdGRLPqJYObapBCdzBVeDgYWdJnFPFb7qzpg=="], - - "@azure/core-client": ["@azure/core-client@1.10.1", "", { "dependencies": { "@azure/abort-controller": "^2.1.2", "@azure/core-auth": "^1.10.0", "@azure/core-rest-pipeline": "^1.22.0", "@azure/core-tracing": "^1.3.0", "@azure/core-util": "^1.13.0", "@azure/logger": "^1.3.0", "tslib": "^2.6.2" } }, "sha512-Nh5PhEOeY6PrnxNPsEHRr9eimxLwgLlpmguQaHKBinFYA/RU9+kOYVOQqOrTsCL+KSxrLLl1gD8Dk5BFW/7l/w=="], - - "@azure/core-rest-pipeline": ["@azure/core-rest-pipeline@1.23.0", "", { "dependencies": { "@azure/abort-controller": "^2.1.2", "@azure/core-auth": "^1.10.0", "@azure/core-tracing": "^1.3.0", "@azure/core-util": "^1.13.0", "@azure/logger": "^1.3.0", "@typespec/ts-http-runtime": "^0.3.4", "tslib": "^2.6.2" } }, "sha512-Evs1INHo+jUjwHi1T6SG6Ua/LHOQBCLuKEEE6efIpt4ZOoNonaT1kP32GoOcdNDbfqsD2445CPri3MubBy5DEQ=="], - - "@azure/core-tracing": ["@azure/core-tracing@1.3.1", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-9MWKevR7Hz8kNzzPLfX4EAtGM2b8mr50HPDBvio96bURP/9C+HjdH3sBlLSNNrvRAr5/k/svoH457gB5IKpmwQ=="], - - "@azure/core-util": ["@azure/core-util@1.13.1", "", { "dependencies": { "@azure/abort-controller": "^2.1.2", "@typespec/ts-http-runtime": "^0.3.0", "tslib": "^2.6.2" } }, "sha512-XPArKLzsvl0Hf0CaGyKHUyVgF7oDnhKoP85Xv6M4StF/1AhfORhZudHtOyf2s+FcbuQ9dPRAjB8J2KvRRMUK2A=="], - - "@azure/identity": ["@azure/identity@4.13.1", "", { "dependencies": { "@azure/abort-controller": "^2.0.0", "@azure/core-auth": "^1.9.0", "@azure/core-client": "^1.9.2", "@azure/core-rest-pipeline": "^1.17.0", "@azure/core-tracing": "^1.0.0", "@azure/core-util": "^1.11.0", "@azure/logger": "^1.0.0", "@azure/msal-browser": "^5.5.0", "@azure/msal-node": "^5.1.0", "open": "^10.1.0", "tslib": "^2.2.0" } }, "sha512-5C/2WD5Vb1lHnZS16dNQRPMjN6oV/Upba+C9nBIs15PmOi6A3ZGs4Lr2u60zw4S04gi+u3cEXiqTVP7M4Pz3kw=="], - - "@azure/logger": ["@azure/logger@1.3.0", "", { "dependencies": { "@typespec/ts-http-runtime": "^0.3.0", "tslib": "^2.6.2" } }, "sha512-fCqPIfOcLE+CGqGPd66c8bZpwAji98tZ4JI9i/mlTNTlsIWslCfpg48s/ypyLxZTump5sypjrKn2/kY7q8oAbA=="], - - "@azure/msal-browser": ["@azure/msal-browser@5.6.3", "", { "dependencies": { "@azure/msal-common": "16.4.1" } }, "sha512-sTjMtUm+bJpENU/1WlRzHEsgEHppZDZ1EtNyaOODg/sQBtMxxJzGB+MOCM+T2Q5Qe1fKBrdxUmjyRxm0r7Ez9w=="], - "@azure/msal-common": ["@azure/msal-common@16.10.0", "", {}, "sha512-iYtjpanlv6963Jprs0MvzIap07V+QhultjQctfbEDQCflsDAEeO3R7XnVA5gk30fhoBFLdgJT7VqO0TGsEsN9w=="], "@azure/msal-node": ["@azure/msal-node@5.3.0", "", { "dependencies": { "@azure/msal-common": "16.10.0", "jsonwebtoken": "^9.0.0" } }, "sha512-fXtJX811pX8y8QlrQqBSH6+plvWyKZDI0IxkheAcyAw9OtcpXyFivmTC7eGUqutLWaDlKXuQ3yOESD4zAmkjHg=="], @@ -2312,7 +2295,7 @@ "@opentelemetry/sdk-logs": ["@opentelemetry/sdk-logs@0.214.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.214.0", "@opentelemetry/core": "2.6.1", "@opentelemetry/resources": "2.6.1", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.4.0 <1.10.0" } }, "sha512-zf6acnScjhsaBUU22zXZ/sLWim1dfhUAbGXdMmHmNG3LfBnQ3DKsOCITb2IZwoUsNNMTogqFKBnlIPPftUgGwA=="], - "@opentelemetry/sdk-metrics": ["@opentelemetry/sdk-metrics@2.0.0", "", { "dependencies": { "@opentelemetry/core": "2.0.0", "@opentelemetry/resources": "2.0.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.9.0 <1.10.0" } }, "sha512-Bvy8QDjO05umd0+j+gDeWcTaVa1/R2lDj/eOvjzpm8VQj1K1vVZJuyjThpV5/lSHyYW2JaHF2IQ7Z8twJFAhjA=="], + "@opentelemetry/sdk-metrics": ["@opentelemetry/sdk-metrics@2.6.1", "", { "dependencies": { "@opentelemetry/core": "2.6.1", "@opentelemetry/resources": "2.6.1" }, "peerDependencies": { "@opentelemetry/api": ">=1.9.0 <1.10.0" } }, "sha512-9t9hJHX15meBy2NmTJxL+NJfXmnausR2xUDvE19XQce0Qi/GBtDGamU8nS1RMbdgDmhgpm3VaOu2+fiS/SfTpQ=="], "@opentelemetry/sdk-node": ["@opentelemetry/sdk-node@0.214.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.214.0", "@opentelemetry/configuration": "0.214.0", "@opentelemetry/context-async-hooks": "2.6.1", "@opentelemetry/core": "2.6.1", "@opentelemetry/exporter-logs-otlp-grpc": "0.214.0", "@opentelemetry/exporter-logs-otlp-http": "0.214.0", "@opentelemetry/exporter-logs-otlp-proto": "0.214.0", "@opentelemetry/exporter-metrics-otlp-grpc": "0.214.0", "@opentelemetry/exporter-metrics-otlp-http": "0.214.0", "@opentelemetry/exporter-metrics-otlp-proto": "0.214.0", "@opentelemetry/exporter-prometheus": "0.214.0", "@opentelemetry/exporter-trace-otlp-grpc": "0.214.0", "@opentelemetry/exporter-trace-otlp-http": "0.214.0", "@opentelemetry/exporter-trace-otlp-proto": "0.214.0", "@opentelemetry/exporter-zipkin": "2.6.1", "@opentelemetry/instrumentation": "0.214.0", "@opentelemetry/otlp-exporter-base": "0.214.0", "@opentelemetry/propagator-b3": "2.6.1", "@opentelemetry/propagator-jaeger": "2.6.1", "@opentelemetry/resources": "2.6.1", "@opentelemetry/sdk-logs": "0.214.0", "@opentelemetry/sdk-metrics": "2.6.1", "@opentelemetry/sdk-trace-base": "2.6.1", "@opentelemetry/sdk-trace-node": "2.6.1", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-gl2XvQBJuPjhGcw9SsnQO5qxChAPMuGRPFaD8lqtF+Cey91NgGUQ0sio2vlDFOSm3JOLzc44vL+OAfx1dXuZjg=="], @@ -3254,8 +3237,6 @@ "@typescript/vfs": ["@typescript/vfs@1.6.4", "", { "dependencies": { "debug": "^4.4.3" }, "peerDependencies": { "typescript": "*" } }, "sha512-PJFXFS4ZJKiJ9Qiuix6Dz/OwEIqHD7Dme1UwZhTK11vR+5dqW2ACbdndWQexBzCx+CPuMe5WBYQWCsFyGlQLlQ=="], - "@typespec/ts-http-runtime": ["@typespec/ts-http-runtime@0.3.5", "", { "dependencies": { "http-proxy-agent": "^7.0.0", "https-proxy-agent": "^7.0.0", "tslib": "^2.6.2" } }, "sha512-yURCknZhvywvQItHMMmFSo+fq5arCUIyz/CVk7jD89MSai7dkaX8ufjCWp3NttLojoTVbcE72ri+be/TnEbMHw=="], - "@ungap/structured-clone": ["@ungap/structured-clone@1.3.0", "", {}, "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g=="], "@upsetjs/venn.js": ["@upsetjs/venn.js@2.0.0", "", { "optionalDependencies": { "d3-selection": "^3.0.0", "d3-transition": "^3.0.1" } }, "sha512-WbBhLrooyePuQ1VZxrJjtLvTc4NVfpOyKx0sKqioq9bX1C1m7Jgykkn8gLrtwumBioXIqam8DLxp88Adbue6Hw=="], @@ -4968,22 +4949,12 @@ "performance-now": ["performance-now@2.1.0", "", {}, "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow=="], - "pg": ["pg@8.20.0", "", { "dependencies": { "pg-connection-string": "^2.12.0", "pg-pool": "^3.13.0", "pg-protocol": "^1.13.0", "pg-types": "2.2.0", "pgpass": "1.0.5" }, "optionalDependencies": { "pg-cloudflare": "^1.3.0" }, "peerDependencies": { "pg-native": ">=3.0.1" }, "optionalPeers": ["pg-native"] }, "sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA=="], - - "pg-cloudflare": ["pg-cloudflare@1.3.0", "", {}, "sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ=="], - - "pg-connection-string": ["pg-connection-string@2.12.0", "", {}, "sha512-U7qg+bpswf3Cs5xLzRqbXbQl85ng0mfSV/J0nnA31MCLgvEaAo7CIhmeyrmJpOr7o+zm0rXK+hNnT5l9RHkCkQ=="], - "pg-int8": ["pg-int8@1.0.1", "", {}, "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw=="], - "pg-pool": ["pg-pool@3.13.0", "", { "peerDependencies": { "pg": ">=8.0" } }, "sha512-gB+R+Xud1gLFuRD/QgOIgGOBE2KCQPaPwkzBBGC9oG69pHTkhQeIuejVIk3/cnDyX39av2AxomQiyPT13WKHQA=="], - "pg-protocol": ["pg-protocol@1.13.0", "", {}, "sha512-zzdvXfS6v89r6v7OcFCHfHlyG/wvry1ALxZo4LqgUoy7W9xhBDMaqOuMiF3qEV45VqsN6rdlcehHrfDtlCPc8w=="], "pg-types": ["pg-types@2.2.0", "", { "dependencies": { "pg-int8": "1.0.1", "postgres-array": "~2.0.0", "postgres-bytea": "~1.0.0", "postgres-date": "~1.0.4", "postgres-interval": "^1.1.0" } }, "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA=="], - "pgpass": ["pgpass@1.0.5", "", { "dependencies": { "split2": "^4.1.0" } }, "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug=="], - "piccolore": ["piccolore@0.1.3", "", {}, "sha512-o8bTeDWjE086iwKrROaDf31K0qC/BENdm15/uH9usSC/uZjJOKb2YGiVHfLY4GhwsERiPI1jmwI2XrA7ACOxVw=="], "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], @@ -5440,16 +5411,12 @@ "split-string": ["split-string@3.1.0", "", { "dependencies": { "extend-shallow": "^3.0.0" } }, "sha512-NzNVhJDYpwceVVii8/Hu6DKfD2G+NrQHlS/V/qgv763EYudVwEcMQNxd2lh+0VrUByXN/oJkl5grOhYWvQUYiw=="], - "split2": ["split2@4.2.0", "", {}, "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg=="], - "spotify-web-api-node": ["spotify-web-api-node@5.0.2", "", { "dependencies": { "superagent": "^6.1.0" } }, "sha512-r82dRWU9PMimHvHEzL0DwEJrzFk+SMCVfq249SLt3I7EFez7R+jeoKQd+M1//QcnjqlXPs2am4DFsGk8/GCsrA=="], "sprintf-js": ["sprintf-js@1.1.3", "", {}, "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA=="], "sql-highlight": ["sql-highlight@6.1.0", "", {}, "sha512-ed7OK4e9ywpE7pgRMkMQmZDPKSVdm0oX5IEtZiKnFucSF0zu6c80GZBe38UqHuVhTWJ9xsKgSMjCG2bml86KvA=="], - "sql.js": ["sql.js@1.14.1", "", {}, "sha512-gcj8zBWU5cFsi9WUP+4bFNXAyF1iRpA3LLyS/DP5xlrNzGmPIizUeBggKa8DbDwdqaKwUcTEnChtd2grWo/x/A=="], - "srvx": ["srvx@0.11.15", "", { "bin": { "srvx": "bin/srvx.mjs" } }, "sha512-iXsux0UcOjdvs0LCMa2Ws3WwcDUozA3JN3BquNXkaFPP7TpRqgunKdEgoZ/uwb1J6xaYHfxtz9Twlh6yzwM6Tg=="], "sshpk": ["sshpk@1.18.0", "", { "dependencies": { "asn1": "~0.2.3", "assert-plus": "^1.0.0", "bcrypt-pbkdf": "^1.0.0", "dashdash": "^1.12.0", "ecc-jsbn": "~0.1.1", "getpass": "^0.1.1", "jsbn": "~0.1.0", "safer-buffer": "^2.0.2", "tweetnacl": "~0.14.0" }, "bin": { "sshpk-conv": "bin/sshpk-conv", "sshpk-sign": "bin/sshpk-sign", "sshpk-verify": "bin/sshpk-verify" } }, "sha512-2p2KJZTSqQ/I3+HX42EpYOa2l3f8Erv8MWKsy2I9uf4wA7yFIkXRffYdsx86y6z4vHtV8u7g+pPlr8/4ouAxsQ=="], @@ -5882,10 +5849,6 @@ "@astrojs/react/vite": ["vite@7.3.2", "", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg=="], - "@azure/identity/@azure/msal-node": ["@azure/msal-node@5.1.2", "", { "dependencies": { "@azure/msal-common": "16.4.1", "jsonwebtoken": "^9.0.0", "uuid": "^8.3.0" } }, "sha512-DoeSJ9U5KPAIZoHsPywvfEj2MhBniQe0+FSpjLUTdWoIkI999GB5USkW6nNEHnIaLVxROHXvprWA1KzdS1VQ4A=="], - - "@azure/msal-browser/@azure/msal-common": ["@azure/msal-common@16.4.1", "", {}, "sha512-Bl8f+w37xkXsYh7QRkAKCFGYtWMYuOVO7Lv+BxILrvGz3HbIEF22Pt0ugyj0QPOl6NLrHcnNUQ9yeew98P/5iw=="], - "@babel/core/@babel/parser": ["@babel/parser@7.29.2", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA=="], "@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], @@ -6136,20 +6099,12 @@ "@opentelemetry/exporter-metrics-otlp-grpc/@opentelemetry/resources": ["@opentelemetry/resources@2.6.1", "", { "dependencies": { "@opentelemetry/core": "2.6.1", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-lID/vxSuKWXM55XhAKNoYXu9Cutoq5hFdkbTdI/zDKQktXzcWBVhNsOkiZFTMU9UtEWuGRNe0HUgmsFldIdxVA=="], - "@opentelemetry/exporter-metrics-otlp-grpc/@opentelemetry/sdk-metrics": ["@opentelemetry/sdk-metrics@2.6.1", "", { "dependencies": { "@opentelemetry/core": "2.6.1", "@opentelemetry/resources": "2.6.1" }, "peerDependencies": { "@opentelemetry/api": ">=1.9.0 <1.10.0" } }, "sha512-9t9hJHX15meBy2NmTJxL+NJfXmnausR2xUDvE19XQce0Qi/GBtDGamU8nS1RMbdgDmhgpm3VaOu2+fiS/SfTpQ=="], - "@opentelemetry/exporter-metrics-otlp-http/@opentelemetry/resources": ["@opentelemetry/resources@2.6.1", "", { "dependencies": { "@opentelemetry/core": "2.6.1", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-lID/vxSuKWXM55XhAKNoYXu9Cutoq5hFdkbTdI/zDKQktXzcWBVhNsOkiZFTMU9UtEWuGRNe0HUgmsFldIdxVA=="], - "@opentelemetry/exporter-metrics-otlp-http/@opentelemetry/sdk-metrics": ["@opentelemetry/sdk-metrics@2.6.1", "", { "dependencies": { "@opentelemetry/core": "2.6.1", "@opentelemetry/resources": "2.6.1" }, "peerDependencies": { "@opentelemetry/api": ">=1.9.0 <1.10.0" } }, "sha512-9t9hJHX15meBy2NmTJxL+NJfXmnausR2xUDvE19XQce0Qi/GBtDGamU8nS1RMbdgDmhgpm3VaOu2+fiS/SfTpQ=="], - "@opentelemetry/exporter-metrics-otlp-proto/@opentelemetry/resources": ["@opentelemetry/resources@2.6.1", "", { "dependencies": { "@opentelemetry/core": "2.6.1", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-lID/vxSuKWXM55XhAKNoYXu9Cutoq5hFdkbTdI/zDKQktXzcWBVhNsOkiZFTMU9UtEWuGRNe0HUgmsFldIdxVA=="], - "@opentelemetry/exporter-metrics-otlp-proto/@opentelemetry/sdk-metrics": ["@opentelemetry/sdk-metrics@2.6.1", "", { "dependencies": { "@opentelemetry/core": "2.6.1", "@opentelemetry/resources": "2.6.1" }, "peerDependencies": { "@opentelemetry/api": ">=1.9.0 <1.10.0" } }, "sha512-9t9hJHX15meBy2NmTJxL+NJfXmnausR2xUDvE19XQce0Qi/GBtDGamU8nS1RMbdgDmhgpm3VaOu2+fiS/SfTpQ=="], - "@opentelemetry/exporter-prometheus/@opentelemetry/resources": ["@opentelemetry/resources@2.6.1", "", { "dependencies": { "@opentelemetry/core": "2.6.1", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-lID/vxSuKWXM55XhAKNoYXu9Cutoq5hFdkbTdI/zDKQktXzcWBVhNsOkiZFTMU9UtEWuGRNe0HUgmsFldIdxVA=="], - "@opentelemetry/exporter-prometheus/@opentelemetry/sdk-metrics": ["@opentelemetry/sdk-metrics@2.6.1", "", { "dependencies": { "@opentelemetry/core": "2.6.1", "@opentelemetry/resources": "2.6.1" }, "peerDependencies": { "@opentelemetry/api": ">=1.9.0 <1.10.0" } }, "sha512-9t9hJHX15meBy2NmTJxL+NJfXmnausR2xUDvE19XQce0Qi/GBtDGamU8nS1RMbdgDmhgpm3VaOu2+fiS/SfTpQ=="], - "@opentelemetry/exporter-trace-otlp-grpc/@opentelemetry/resources": ["@opentelemetry/resources@2.6.1", "", { "dependencies": { "@opentelemetry/core": "2.6.1", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-lID/vxSuKWXM55XhAKNoYXu9Cutoq5hFdkbTdI/zDKQktXzcWBVhNsOkiZFTMU9UtEWuGRNe0HUgmsFldIdxVA=="], "@opentelemetry/exporter-trace-otlp-grpc/@opentelemetry/sdk-trace-base": ["@opentelemetry/sdk-trace-base@2.6.1", "", { "dependencies": { "@opentelemetry/core": "2.6.1", "@opentelemetry/resources": "2.6.1", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-r86ut4T1e8vNwB35CqCcKd45yzqH6/6Wzvpk2/cZB8PsPLlZFTvrh8yfOS3CYZYcUmAx4hHTZJ8AO8Dj8nrdhw=="], @@ -6184,24 +6139,18 @@ "@opentelemetry/otlp-transformer/@opentelemetry/resources": ["@opentelemetry/resources@2.6.1", "", { "dependencies": { "@opentelemetry/core": "2.6.1", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-lID/vxSuKWXM55XhAKNoYXu9Cutoq5hFdkbTdI/zDKQktXzcWBVhNsOkiZFTMU9UtEWuGRNe0HUgmsFldIdxVA=="], - "@opentelemetry/otlp-transformer/@opentelemetry/sdk-metrics": ["@opentelemetry/sdk-metrics@2.6.1", "", { "dependencies": { "@opentelemetry/core": "2.6.1", "@opentelemetry/resources": "2.6.1" }, "peerDependencies": { "@opentelemetry/api": ">=1.9.0 <1.10.0" } }, "sha512-9t9hJHX15meBy2NmTJxL+NJfXmnausR2xUDvE19XQce0Qi/GBtDGamU8nS1RMbdgDmhgpm3VaOu2+fiS/SfTpQ=="], - "@opentelemetry/otlp-transformer/@opentelemetry/sdk-trace-base": ["@opentelemetry/sdk-trace-base@2.6.1", "", { "dependencies": { "@opentelemetry/core": "2.6.1", "@opentelemetry/resources": "2.6.1", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-r86ut4T1e8vNwB35CqCcKd45yzqH6/6Wzvpk2/cZB8PsPLlZFTvrh8yfOS3CYZYcUmAx4hHTZJ8AO8Dj8nrdhw=="], "@opentelemetry/resources/@opentelemetry/core": ["@opentelemetry/core@2.8.0", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-hd1Lfh8p545nNz+jq1Ejfz+Mn1hyLuxYn1YzTfFNrxr8urEWMNQLPf1Th8kjOH+HxwawCrtgBp8JpBUR4ZSgww=="], "@opentelemetry/sdk-logs/@opentelemetry/resources": ["@opentelemetry/resources@2.6.1", "", { "dependencies": { "@opentelemetry/core": "2.6.1", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-lID/vxSuKWXM55XhAKNoYXu9Cutoq5hFdkbTdI/zDKQktXzcWBVhNsOkiZFTMU9UtEWuGRNe0HUgmsFldIdxVA=="], - "@opentelemetry/sdk-metrics/@opentelemetry/core": ["@opentelemetry/core@2.0.0", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-SLX36allrcnVaPYG3R78F/UZZsBsvbc7lMCLx37LyH5MJ1KAAZ2E3mW9OAD3zGz0G8q/BtoS5VUrjzDydhD6LQ=="], - - "@opentelemetry/sdk-metrics/@opentelemetry/resources": ["@opentelemetry/resources@2.0.0", "", { "dependencies": { "@opentelemetry/core": "2.0.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-rnZr6dML2z4IARI4zPGQV4arDikF/9OXZQzrC01dLmn0CZxU5U5OLd/m1T7YkGRj5UitjeoCtg/zorlgMQcdTg=="], + "@opentelemetry/sdk-metrics/@opentelemetry/resources": ["@opentelemetry/resources@2.6.1", "", { "dependencies": { "@opentelemetry/core": "2.6.1", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-lID/vxSuKWXM55XhAKNoYXu9Cutoq5hFdkbTdI/zDKQktXzcWBVhNsOkiZFTMU9UtEWuGRNe0HUgmsFldIdxVA=="], "@opentelemetry/sdk-node/@opentelemetry/context-async-hooks": ["@opentelemetry/context-async-hooks@2.6.1", "", { "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-XHzhwRNkBpeP8Fs/qjGrAf9r9PRv67wkJQ/7ZPaBQQ68DYlTBBx5MF9LvPx7mhuXcDessKK2b+DcxqwpgkcivQ=="], "@opentelemetry/sdk-node/@opentelemetry/resources": ["@opentelemetry/resources@2.6.1", "", { "dependencies": { "@opentelemetry/core": "2.6.1", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-lID/vxSuKWXM55XhAKNoYXu9Cutoq5hFdkbTdI/zDKQktXzcWBVhNsOkiZFTMU9UtEWuGRNe0HUgmsFldIdxVA=="], - "@opentelemetry/sdk-node/@opentelemetry/sdk-metrics": ["@opentelemetry/sdk-metrics@2.6.1", "", { "dependencies": { "@opentelemetry/core": "2.6.1", "@opentelemetry/resources": "2.6.1" }, "peerDependencies": { "@opentelemetry/api": ">=1.9.0 <1.10.0" } }, "sha512-9t9hJHX15meBy2NmTJxL+NJfXmnausR2xUDvE19XQce0Qi/GBtDGamU8nS1RMbdgDmhgpm3VaOu2+fiS/SfTpQ=="], - "@opentelemetry/sdk-node/@opentelemetry/sdk-trace-base": ["@opentelemetry/sdk-trace-base@2.6.1", "", { "dependencies": { "@opentelemetry/core": "2.6.1", "@opentelemetry/resources": "2.6.1", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-r86ut4T1e8vNwB35CqCcKd45yzqH6/6Wzvpk2/cZB8PsPLlZFTvrh8yfOS3CYZYcUmAx4hHTZJ8AO8Dj8nrdhw=="], "@opentelemetry/sdk-node/@opentelemetry/sdk-trace-node": ["@opentelemetry/sdk-trace-node@2.6.1", "", { "dependencies": { "@opentelemetry/context-async-hooks": "2.6.1", "@opentelemetry/core": "2.6.1", "@opentelemetry/sdk-trace-base": "2.6.1" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-Hh2i4FwHWRFhnO2Q/p6svMxy8MPsNCG0uuzUY3glqm0rwM0nQvbTO1dXSp9OqQoTKXcQzaz9q1f65fsurmOhNw=="], @@ -6820,10 +6769,6 @@ "@astrojs/react/@vitejs/plugin-react/@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.3", "", {}, "sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q=="], - "@azure/identity/@azure/msal-node/@azure/msal-common": ["@azure/msal-common@16.4.1", "", {}, "sha512-Bl8f+w37xkXsYh7QRkAKCFGYtWMYuOVO7Lv+BxILrvGz3HbIEF22Pt0ugyj0QPOl6NLrHcnNUQ9yeew98P/5iw=="], - - "@azure/identity/@azure/msal-node/uuid": ["uuid@8.3.2", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg=="], - "@babel/helper-annotate-as-pure/@babel/types/@babel/helper-string-parser": ["@babel/helper-string-parser@8.0.0", "", {}, "sha512-6mJgmFFFIIO82vvoLt9XtRC7/TkzXfts1t/SpRX4IHSzMgqoPYCWesVu1udUPUWioAE/2fcG6WuI8zrkE1gwrg=="], "@babel/helper-annotate-as-pure/@babel/types/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@8.0.4", "", {}, "sha512-4wFaiLd0bVo4cIoTXI3zKI038NIWE/cr3jvBjejOVYVxV/m8Ltav1USiGzG1fmS5J2RhgEOgXNNK46cRPnRsrg=="], From ac613f003fd39dd1da47ab71b7e52a8bb666c29e Mon Sep 17 00:00:00 2001 From: Charlie Date: Fri, 28 Aug 2026 08:04:10 +0000 Subject: [PATCH 3/3] refactor(host-selfhost): extract SSO admission helpers, trim comments --- apps/host-selfhost/src/auth/better-auth.ts | 48 +--------------------- apps/host-selfhost/src/auth/sso.test.ts | 2 +- apps/host-selfhost/src/auth/sso.ts | 46 +++++++++++++++++++++ 3 files changed, 49 insertions(+), 47 deletions(-) create mode 100644 apps/host-selfhost/src/auth/sso.ts diff --git a/apps/host-selfhost/src/auth/better-auth.ts b/apps/host-selfhost/src/auth/better-auth.ts index b4fee52589..fbd1431d56 100644 --- a/apps/host-selfhost/src/auth/better-auth.ts +++ b/apps/host-selfhost/src/auth/better-auth.ts @@ -13,9 +13,10 @@ import { type Client } from "@libsql/client"; import { LibsqlDialect, type LibsqlDialectConfig } from "@libsql/kysely-libsql"; import { Context } from "effect"; -import { loadConfig, type SsoConfig } from "../config"; +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 @@ -32,51 +33,6 @@ interface SignupGate { // creation (the seed, or a future admin "add user") flows through other paths. const SIGNUP_PATH = "/sign-up/email"; -// Better Auth serves OAuth sign-in callbacks at `/oauth2/callback/:providerId` -// (the genericOAuth plugin, which carries the configured SSO provider) and -// `/callback/:providerId` (built-in social providers) — the only paths an -// IdP-initiated user creation arrives on. Server-side creation (the seed, -// admin add-user) never carries either, so this cleanly splits "a stranger -// signed in at the IdP" from every trusted path. -const isOAuthCallback = (path: string | undefined): boolean => - path?.startsWith("/oauth2/callback/") === true || path?.startsWith("/callback/") === true; - -// The domain of a well-formed address, or null — so a malformed email can never -// match an allowlist entry (`emailDomain("@example.com")` is null, not "example.com"). -export const emailDomain = (email: string): string | null => { - const at = email.lastIndexOf("@"); - if (at <= 0 || at === email.length - 1) return null; - return email.slice(at + 1).toLowerCase(); -}; - -// Admission = the IdP vouches for the address (`email_verified`, mapped to -// `emailVerified` by the genericOAuth callback) AND its domain is allowlisted. -// The verified check is load-bearing: an unverified claim is whatever the -// account holder typed, so without it anyone could register an IdP account -// with a made-up allowlisted address and walk in. -const isAdmitted = (sso: SsoConfig, user: { email: string; emailVerified: boolean }): boolean => { - if (!user.emailVerified) return false; - const domain = emailDomain(user.email); - return domain !== null && sso.allowedDomains.includes(domain); -}; - -// The genericOAuth registration for the configured provider. Everything is -// derived from the IdP's discovery document; PKCE is on unconditionally (any -// OIDC-compliant IdP supports it). For Google with a single allowed domain, -// `hd` pre-filters the account chooser to that Workspace domain — a UX hint -// only (Google treats it as advisory); the create-hook gate is the enforcement. -const ssoProviderConfig = (sso: SsoConfig) => ({ - providerId: sso.providerId, - clientId: sso.clientId, - clientSecret: sso.clientSecret, - discoveryUrl: sso.discoveryUrl, - scopes: ["openid", "email", "profile"], - pkce: true, - ...(sso.providerId === "google" && sso.allowedDomains.length === 1 - ? { authorizationUrlParams: { hd: sso.allowedDomains[0]! } } - : {}), -}); - // --------------------------------------------------------------------------- // Better Auth instance over the SAME libSQL CONNECTION as the FumaDB executor // tables ("one connection, two schema regions"). diff --git a/apps/host-selfhost/src/auth/sso.test.ts b/apps/host-selfhost/src/auth/sso.test.ts index a1ed18a0d5..92f03d8791 100644 --- a/apps/host-selfhost/src/auth/sso.test.ts +++ b/apps/host-selfhost/src/auth/sso.test.ts @@ -51,7 +51,7 @@ process.env.EXECUTOR_SSO_CLIENT_SECRET = registration.client_secret; process.env.EXECUTOR_SSO_ALLOWED_DOMAINS = "Example.com, @second.example ,"; const { loadConfig } = await import("../config"); -const { emailDomain } = await import("./better-auth"); +const { emailDomain } = await import("./sso"); const { makeSelfHostApiHandler } = await import("../app"); const { handler, dispose } = await makeSelfHostApiHandler(); diff --git a/apps/host-selfhost/src/auth/sso.ts b/apps/host-selfhost/src/auth/sso.ts new file mode 100644 index 0000000000..7d2d00a3df --- /dev/null +++ b/apps/host-selfhost/src/auth/sso.ts @@ -0,0 +1,46 @@ +import { type SsoConfig } from "../config"; + +// Better Auth serves OAuth sign-in callbacks at `/oauth2/callback/:providerId` +// (genericOAuth) and `/callback/:providerId` (built-in social providers) — the +// only paths an IdP-initiated user creation arrives on, so this splits "a +// stranger signed in at the IdP" from server-side creation (the seed, admin +// add-user), which never carries either. +export const isOAuthCallback = (path: string | undefined): boolean => + path?.startsWith("/oauth2/callback/") === true || path?.startsWith("/callback/") === true; + +// The domain of a well-formed address, or null — so a malformed email can never +// match an allowlist entry (`emailDomain("@example.com")` is null, not "example.com"). +export const emailDomain = (email: string): string | null => { + const at = email.lastIndexOf("@"); + if (at <= 0 || at === email.length - 1) return null; + return email.slice(at + 1).toLowerCase(); +}; + +// Admission = the IdP vouches for the address (`email_verified`, mapped to +// `emailVerified` by the genericOAuth callback) AND its domain is allowlisted. +// Without the verified check, anyone could register an IdP account with a +// made-up allowlisted address and walk in. +export const isAdmitted = ( + sso: SsoConfig, + user: { email: string; emailVerified: boolean }, +): boolean => { + if (!user.emailVerified) return false; + const domain = emailDomain(user.email); + return domain !== null && sso.allowedDomains.includes(domain); +}; + +// The genericOAuth registration for the configured provider, derived from its +// OIDC discovery document. For Google with a single allowed domain, `hd` +// pre-filters the account chooser — a UX hint only (Google treats it as +// advisory); the create-hook gate is the enforcement. +export const ssoProviderConfig = (sso: SsoConfig) => ({ + providerId: sso.providerId, + clientId: sso.clientId, + clientSecret: sso.clientSecret, + discoveryUrl: sso.discoveryUrl, + scopes: ["openid", "email", "profile"], + pkce: true, + ...(sso.providerId === "google" && sso.allowedDomains.length === 1 + ? { authorizationUrlParams: { hd: sso.allowedDomains[0]! } } + : {}), +});