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 31225ca7a0..a10377c47d 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 57d8eac86e..1d714312fd 100644 --- a/apps/host-selfhost/src/auth/better-auth.ts +++ b/apps/host-selfhost/src/auth/better-auth.ts @@ -1,6 +1,13 @@ import { betterAuth, type BetterAuthOptions } from "better-auth"; import { APIError } from "better-auth/api"; -import { admin, bearer, deviceAuthorization, mcp, organization } from "better-auth/plugins"; +import { + admin, + bearer, + deviceAuthorization, + genericOAuth, + mcp, + organization, +} from "better-auth/plugins"; import { apiKey } from "@better-auth/api-key"; import { type Client } from "@libsql/client"; import { LibsqlDialect, type LibsqlDialectConfig } from "@libsql/kysely-libsql"; @@ -9,6 +16,7 @@ import { Context } from "effect"; import { loadConfig } from "../config"; import { seedOrgAndAdmin } from "./seed"; import { consumeInviteCode, ensureInviteCodeTable, findRedeemableCode } from "./invites"; +import { isAdmitted, isOAuthCallback, ssoProviderConfig } from "./sso"; // The self-service signup gate: present only on the live (phase-2) auth // instance, so the bootstrap seed's `createUser` — which @@ -165,6 +173,14 @@ const makeAuthOptions = (client: Client, getOrganizationId: () => string, gate?: // is the page the user opens to confirm the code — the self-host app serves // it at /device (this is also the Better Auth default; pinned for clarity). deviceAuthorization({ verificationUri: "/device" }), + // The operator-configured SSO provider (see config.ts), spoken over plain + // OIDC discovery so Google, Okta, Entra, or any compliant IdP slots in — + // and so tests can point it at an emulated IdP. The domain gate below is + // what admits or refuses the users this creates; enabling the provider + // alone never opens registration. Always in the plugin tuple (an empty + // provider list serves no routes that match) so the inferred `auth.api` + // shape doesn't depend on the environment. + genericOAuth({ config: config.sso ? [ssoProviderConfig(config.sso)] : [] }), // `consentPage` makes the MCP authorize flow redirect to a human approval // screen instead of auto-issuing a code — but ONLY when the request // carries `prompt=consent`. MCP clients don't send that, so the self-host @@ -199,8 +215,26 @@ const makeAuthOptions = (client: Client, getOrganizationId: () => string, gate?: ? { user: { create: { - before: async (_user, context) => { - if (context?.path !== SIGNUP_PATH) return; + before: async (user, context) => { + if (context?.path !== SIGNUP_PATH) { + // SSO sign-ups arrive on an OAuth callback path; the + // verified-domain allowlist gates them in place of an + // invite code. Server-side creation (the seed, admin + // add-user) passes. + const sso = config.sso; + if ( + isOAuthCallback(context?.path) && + !(sso !== undefined && isAdmitted(sso, user)) + ) { + // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: a Better Auth create hook rejects a request by throwing APIError + throw new APIError("FORBIDDEN", { + message: sso + ? `Sign-ups are restricted to verified ${sso.allowedDomains.map((d) => `@${d}`).join(", ")} accounts.` + : "SSO sign-up is not enabled on this instance.", + }); + } + return; + } if (await orgHasNoMembers(gate)) return; // first user claims the org const code = inviteCodeFrom(context); if (!code) { @@ -217,9 +251,30 @@ const makeAuthOptions = (client: Client, getOrganizationId: () => string, gate?: } }, after: async (user, context) => { - if (context?.path !== SIGNUP_PATH) return; const auth = gate.getAuth(); if (!auth) return; + if (context?.path !== SIGNUP_PATH) { + // An SSO user that reached `after` was admitted by + // `before`; joining the instance org as a member is what an + // invite redemption would have done. Server-side creation + // (no callback path) is left alone — the seed manages its + // own membership. + const sso = config.sso; + if ( + isOAuthCallback(context?.path) && + sso !== undefined && + isAdmitted(sso, user) + ) { + await auth.api.addMember({ + body: { + userId: user.id, + role: "member", + organizationId: gate.organizationId, + }, + }); + } + return; + } // First user into an empty org becomes its owner (no code). if (await orgHasNoMembers(gate)) { await auth.api.addMember({ 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..92f03d8791 --- /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("./sso"); +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/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]! } } + : {}), +}); diff --git a/apps/host-selfhost/src/config.ts b/apps/host-selfhost/src/config.ts index 9e864f1c94..ab443f3bf3 100644 --- a/apps/host-selfhost/src/config.ts +++ b/apps/host-selfhost/src/config.ts @@ -18,6 +18,26 @@ import { export const SELF_HOST_NAMESPACE = "executor_selfhost"; export const SELF_HOST_SCHEMA_VERSION = "1.0.0"; +/** + * 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 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. */ + readonly allowedDomains: readonly string[]; +} + export interface SelfHostConfig { /** Bind address. Defaults to loopback. */ readonly host: string; @@ -53,6 +73,8 @@ export interface SelfHostConfig { * minutes (the same pattern as MCP_PAUSED_SESSION_IDLE_TIMEOUT_MS on cloud). */ readonly sandboxTimeoutMs: number | undefined; + /** SSO sign-in, or undefined when the operator hasn't configured it. */ + readonly sso: SsoConfig | undefined; /** * How long an MCP session may sit idle before the in-process store evicts it, * or undefined for the store's own default (30 minutes). 0 disables eviction. @@ -172,11 +194,68 @@ export const loadConfig = (): SelfHostConfig => { organizationName: process.env.EXECUTOR_ORG_NAME ?? "Default", orgSlug: resolveOrgSlug(), sandboxTimeoutMs: resolveSandboxTimeoutMs(), + sso: resolveSso(), mcpSessionIdleTtlMs: resolveMcpSessionIdleTtlMs(), toolsSyncTtlMs: resolveToolsSyncTtlMs(), }; }; +// 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_SSO_DISCOVERY_URL is required for provider ${JSON.stringify(providerId)} (the IdP's …/.well-known/openid-configuration URL)`, + ); + } + 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: SSO sign-in without a domain allowlist is open registration; refuse to boot + throw new Error( + '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', + ); + } + 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 // 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..01d8424cd3 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 SSO 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 + display names only — never credentials or allowlists. +export const AuthConfigResponse = Schema.Struct({ + ssoProviders: Schema.Array(Schema.Struct({ id: Schema.String, name: 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..ece96d8215 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,15 @@ export const SystemHandlers = HttpApiBuilder.group(SystemHttpApi, "system", (han return { needsSetup: count === 0 }; }), ) + .handle("authConfig", () => + // 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(() => { + const sso = loadConfig().sso; + return { ssoProviders: sso ? [{ id: sso.providerId, name: sso.providerName }] : [] }; + }), + ) .handle("inviteStatus", ({ params }) => Effect.gen(function* () { const { client } = yield* SelfHostDb; 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 new file mode 100644 index 0000000000..4fe7549923 --- /dev/null +++ b/apps/host-selfhost/web/auth-config.ts @@ -0,0 +1,29 @@ +// 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 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, + ); + if (!response?.ok) return []; + const data = (await response.json().then( + (d) => d, + () => ({}), + )) 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 94ee3411b8..09334add6c 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 { 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. 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 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.) // // 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 [ssoProviders, setSsoProviders] = useState([]); + + useEffect(() => { + let cancelled = false; + void fetchSsoProviders().then((providers) => { + if (!cancelled) setSsoProviders(providers); + }); + return () => { + cancelled = true; + }; + }, []); const signIn = async (event: FormEvent) => { event.preventDefault(); @@ -41,6 +54,22 @@ export const LoginPage = () => { window.location.href = postLogin; }; + const signInWithSso = async (providerId: string) => { + setBusy(true); + setError(null); + // 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.oauth2({ + providerId, + 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,27 @@ export const LoginPage = () => { + {ssoProviders.length > 0 && ( + <> +
+
+ or +
+
+ {ssoProviders.map((provider) => ( + + ))} + + )} ) : (
diff --git a/bun.lock b/bun.lock index 43add47256..09ebe751bc 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", @@ -1391,24 +1392,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=="], @@ -2313,7 +2296,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=="], @@ -3255,8 +3238,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=="], @@ -4969,22 +4950,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=="], @@ -5441,16 +5412,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=="], @@ -5883,10 +5850,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=="], @@ -6137,20 +6100,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=="], @@ -6185,24 +6140,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=="], @@ -6821,10 +6770,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=="],