diff --git a/.env.example b/.env.example index a56977d..63fa2d5 100644 --- a/.env.example +++ b/.env.example @@ -11,21 +11,41 @@ TENANT_PACKAGE_DIR=../examples/fintech # deployment mints, so its own conversations stay identifiable. Unset, the tenant package's id is # used, which tells two packages apart but not two copies of one. # DEPLOYMENT_ID= -# Google sign-in. Leave commented for local development with OPENBOT_DEV_NO_AUTH; uncomment all five -# settings together so authentication is either fully configured or absent. -# -# BETTER_AUTH_SECRET must be a high-entropy secret of at least 32 characters. Generate one: -# openssl rand -base64 32 -# TRUSTED_ORIGINS is where the app is served from, which is port 3010 locally. +# Sign-in. All of this is commented out, and a clone with none of it set is one administrator with +# no sign-in at all, which is how you reach the product without registering an OAuth client first. +# Somewhere other people can get to, an unconfigured deployment refuses to start rather than serving +# an open one. OPENBOT_SINGLE_USER=true says you meant it. +# +# Configure ANY ONE of the three providers to turn sign-in on. Configure several and the sign-in +# screen offers several, which is the normal shape for a company mid-migration. +# +# These four are needed whichever provider you pick: +# BETTER_AUTH_URL is where OAuth callbacks come back to, which is the API on port 3001. +# BETTER_AUTH_SECRET signs session cookies. At least 32 characters: openssl rand -base64 32 +# TRUSTED_ORIGINS is where the app is served from, which is port 3010 locally. +# INITIAL_ADMIN_EMAILS names who is an administrator. Required, because nothing else grants the +# role and no screen can promote somebody later. Re-read on every sign-in, so editing it works. # BETTER_AUTH_URL=http://localhost:3001 # BETTER_AUTH_SECRET= +# INITIAL_ADMIN_EMAILS=admin@example.com +# +# Google. Redirect URI: http://localhost:3001/api/auth/callback/google # GOOGLE_OAUTH_CLIENT_ID= # GOOGLE_OAUTH_CLIENT_SECRET= -# INITIAL_ADMIN_EMAILS=admin@example.com +# +# Microsoft (Entra ID). Redirect URI: http://localhost:3001/api/auth/callback/microsoft +# MICROSOFT_OAUTH_TENANT_ID defaults to `common`, which admits personal Microsoft accounts as well +# as work ones. Put your directory GUID here if you mean only your own company. +# MICROSOFT_OAUTH_CLIENT_ID= +# MICROSOFT_OAUTH_CLIENT_SECRET= +# MICROSOFT_OAUTH_TENANT_ID=common +# +# Okta. Redirect URI: http://localhost:3001/api/auth/callback/okta +# The issuer is what makes it your Okta rather than Okta in general. +# OKTA_OAUTH_CLIENT_ID= +# OKTA_OAUTH_CLIENT_SECRET= +# OKTA_OAUTH_ISSUER=https://example.okta.com/oauth2/default -# Local development only. This admits every request as one local administrator. Keep it explicit and -# never expose a deployment to the internet in this state; production refuses to start with it. -OPENBOT_DEV_NO_AUTH=true TRUSTED_ORIGINS=http://localhost:3010 # CopilotKit Intelligence. Required: the server refuses to start without all four because diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0da5ede..2a14fee 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -145,6 +145,12 @@ jobs: - name: The image boots and serves run: | set -euo pipefail + # `OPENBOT_SINGLE_USER=true` because this boots a deployment with no identity provider, and + # the image sets NODE_ENV=production, where that combination refuses to start rather than + # serve an open deployment. Saying so is the point: the flag is how somebody declares they + # meant it. This was `OPENBOT_DEV_NO_AUTH=1` before, which the code never accepted at all, + # since it compares against the exact string "true"; it did nothing and nothing noticed. + # # Placeholders, not secrets. `loadConfig` refuses to start without Intelligence and a # licence configured, but it only checks that they are present and well-formed; nothing is # contacted at start-up and /api/capabilities reads config alone. So this proves the image @@ -155,7 +161,7 @@ jobs: -e EMBEDDED_POSTGRES=on \ -e KEY_ENCRYPTION_KEY="$(openssl rand -base64 32)" \ -e TRUSTED_ORIGINS=http://localhost:3001 \ - -e OPENBOT_DEV_NO_AUTH=1 \ + -e OPENBOT_SINGLE_USER=true \ -e MANAGED_AGENT_AG_UI_URL=http://127.0.0.1:4201/ag-ui \ -e MANAGED_AGENT_TOKEN=ci-not-a-real-token \ -e INTELLIGENCE_API_URL=https://api.intelligence.copilotkit.ai \ diff --git a/CHANGELOG.md b/CHANGELOG.md index 9067e8a..945ff42 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,15 @@ Newest first. `Unreleased` is what is on `main` and not yet tagged. ## Unreleased +### Upgrading + +Two configurations now refuse to start: + +- A provider configured with no `INITIAL_ADMIN_EMAILS`. Set it to at least one address. +- No provider at all with `NODE_ENV=production`. Configure one, or set `OPENBOT_SINGLE_USER=true`. + +Sessions survive and nobody signs in again. + ### Added - **Releases are cut by a workflow, not by hand.** `Create release PR` bumps the version and promotes @@ -22,6 +31,24 @@ Newest first. `Unreleased` is what is on `main` and not yet tagged. supervised service is respawning. A single `verify` check covers every job, so branch protection needs one entry. The same checks run again against the release commit when a release is published, so they gate the release rather than the proposal for one. +- **Sign in with Google, Microsoft or Okta.** Any one of them turns sign-in on; configure several + and the sign-in screen offers each, on matching buttons carrying each provider's own mark. + `INITIAL_ADMIN_EMAILS` says who is an administrator. It is required whenever a provider is + configured, because nothing else grants the role, and it is now a floor rather than a one-off: + an address it names is made an administrator at every sign-in, so adding somebody to the list + works even after they have already signed in. +- **SAML and OpenID Connect, registered while running.** `/admin/identity-providers` takes the + metadata a company's identity team supplies and registers their own IdP. Somebody then types their + email address on the sign-in screen and the domain decides which provider they are sent to, so a + company mid-merger can run two. Registering, changing or removing one is administrator-only, which + the upstream plugin does not require: it guards those routes with a session, and anybody who could + reach them could register a provider for a domain and mint themselves colleagues. +- **A People screen.** `/admin/people` lists everybody who has signed in, with the provider they came + through and when they were last here, and lets an administrator promote, demote, or remove + somebody. Removing ends the session they are using and stops the next sign-in, keyed on the + address so signing in again through the provider does not quietly create a new account. Every + change is on the audit trail. Somebody named in `INITIAL_ADMIN_EMAILS` cannot be demoted or + removed here, and nobody can do either to themselves. - **One container that runs the whole thing.** The root `Dockerfile` builds an image carrying the app, the API, a Bot computer, and optionally PostgreSQL, supervised together. Point `DATABASE_URL` at a database you already run and the built-in one never starts; leave it unset and the container @@ -92,6 +119,14 @@ Newest first. `Unreleased` is what is on `main` and not yet tagged. ### Changed +- **A deployment with no identity provider is one administrator, without a flag.** That is how a + fresh clone reaches the product. Where `NODE_ENV=production`, an unconfigured deployment now + refuses to start instead, because a public URL where every visitor is an administrator is silent + and looks like it works. `OPENBOT_SINGLE_USER=true` replaces `OPENBOT_DEV_NO_AUTH`, which is still + honoured, and is how somebody says they meant an open deployment. +- **Requires Better Auth 1.7**, which adds an `issuer` to every account. Migrations `0002` to `0004` + add the column, backfill existing rows with their provider's real issuer, and then make it + required, so nobody is asked to sign in again. - **Where a Bot's computer runs is now a plug.** One `ComputerProvider` interface sits under the gateway, with the Docker supervisor as one implementation and a shared computer as another. A computer somewhere else is an adapter rather than a change to the governed path. Thanks to diff --git a/README.md b/README.md index 4ad4e5b..12e5862 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,7 @@ your own machine. > **Alpha, and under active development.** OpenBot is early. Expect rough edges and bugs, and expect things to move. Issues and pull requests are welcome. -> **Runs on your machine.** Everything below is written for a laptop. Out of the box OpenBot runs with `OPENBOT_DEV_NO_AUTH`, which skips signing in and admits every request as one administrator. [Google sign-in](#sign-in-with-google) can be wired up instead. +> **Runs on your machine.** Everything below is written for a laptop. With no identity provider configured OpenBot admits every request as one administrator, so a fresh clone reaches the product without registering an OAuth client. [Sign-in](#sign-in) turns that off. ## What it is @@ -149,6 +149,8 @@ as one replica for now. - **Components instead of prose**: compiled React components live in `app/src/components/gallery/`, sandboxed ones are authored in `/admin/playground` and published with no deployment. Every call asks the server whether the component exists, is published, and is not withheld from that Bot. Data functions are granted per component. - **Governed MCP**: a curated catalogue ships for Atlassian, Box, Slack, Salesforce and ServiceNow. Custom servers must pass URL checks, and any tool not positively classified as a read is treated as a write. - **Skills are instructions, not capabilities**: personal skills attach only to Bots their author owns, deployment skills are admin-owned, and both are invoked with `/` in the composer. +- **Sign in with what your company already has**: Google, Microsoft or Okta from the environment, or a company's own SAML or OpenID Connect provider registered while the deployment runs and routed by email domain. Any one turns sign-in on; several may be configured at once. +- **Decide who gets in**: `/admin/people` lists everybody who has signed in, promotes and demotes them, and removes access, which ends the session they are using and stops the next sign-in. Every change is on the audit trail. - **An audit trail you can read**: `/admin/audit` lists what was permitted, what was refused and what failed, and every refusal carries the rule that caused it. - **Credentials encrypted at rest**: stored through `/admin/credentials`, never returned by an API, and redacted from audit events. - **Loopback by default**: computers bind to `127.0.0.1` and require a per-container token, so nothing reaches a logged-in browser by knowing its port. @@ -191,7 +193,7 @@ Settings worth knowing: | Variable | Use | | ------------------------------------ | ------------------------------------------------------------------------- | -| `OPENBOT_DEV_NO_AUTH` | Admits every request as one administrator. How OpenBot runs today. | +| `OPENBOT_SINGLE_USER` | Admits every request as one administrator where an unconfigured deployment would otherwise refuse to start. | | `OPENAI_BASE_URL` | Answers the OpenAI-shaped calls from somewhere else: a gateway, a proxy. | | `ANTHROPIC_BASE_URL`, `GOOGLE_GENERATIVE_AI_BASE_URL` | The same, for those two APIs. | | `COMPUTER_TOKEN` | Secret every Bot computer request must present. `start.sh` sets one. | @@ -227,25 +229,58 @@ endpoints; keep them private and do not use them to bypass the gateway. More detail: [docs/architecture.md](docs/architecture.md). -## Sign in with Google +## Sign in -`OPENBOT_DEV_NO_AUTH` is the default because it needs no OAuth credentials and no consent screen. To sign in for real instead, create a Google OAuth client and set all four of these together: +Nothing configured means one administrator and no sign-in, which is how a fresh clone reaches the +product. Configure **any one** of Google, Microsoft or Okta to turn sign-in on. Configure more than +one and the sign-in screen offers each of them. + +These four are needed whichever you pick: ```sh -BETTER_AUTH_URL=http://localhost:3001 -BETTER_AUTH_SECRET= # openssl rand -base64 32, at least 32 characters -GOOGLE_OAUTH_CLIENT_ID= -GOOGLE_OAUTH_CLIENT_SECRET= +BETTER_AUTH_URL=http://localhost:3001 # where OAuth callbacks come back to +BETTER_AUTH_SECRET= # openssl rand -base64 32 +TRUSTED_ORIGINS=http://localhost:3010 # where the app is served from +INITIAL_ADMIN_EMAILS=you@example.com # comma separated ``` -Then set the two that decide who gets in and from where: +Then the provider. Register the redirect URI shown beside it. + +```sh +# Google — http://localhost:3001/api/auth/callback/google +GOOGLE_OAUTH_CLIENT_ID= +GOOGLE_OAUTH_CLIENT_SECRET= -- `TRUSTED_ORIGINS` — where the app is served from, `http://localhost:3010` locally. It defaults to `http://localhost:3000`, which is not where `start.sh` serves the app. -- `INITIAL_ADMIN_EMAILS` — comma separated. An address listed here becomes an administrator the first time it signs in; everybody else becomes a user. +# Microsoft — http://localhost:3001/api/auth/callback/microsoft +MICROSOFT_OAUTH_CLIENT_ID= +MICROSOFT_OAUTH_CLIENT_SECRET= +MICROSOFT_OAUTH_TENANT_ID=common # your directory GUID for staff only -Remove `OPENBOT_DEV_NO_AUTH`, then restart: the sign-in button is written into the app's generated config at startup, so it appears only once all four settings are present. Accounts, sessions and roles are stored in the same PostgreSQL database as everything else. +# Okta — http://localhost:3001/api/auth/callback/okta +OKTA_OAUTH_CLIENT_ID= +OKTA_OAUTH_CLIENT_SECRET= +OKTA_OAUTH_ISSUER=https://example.okta.com/oauth2/default +``` -A partial set is refused rather than ignored: the server will not start with `BETTER_AUTH_SECRET` or `BETTER_AUTH_URL` but no client credentials, or with a secret shorter than 32 characters. +Restart. Accounts, sessions and roles are stored in the same PostgreSQL database as everything else. + +- `INITIAL_ADMIN_EMAILS` is required, because nothing else grants the administrator role and no + screen can promote somebody afterwards. It is re-read on every sign-in, so editing it takes effect + the next time that person signs in. +- `MICROSOFT_OAUTH_TENANT_ID` defaults to `common`, which admits personal Microsoft accounts as well + as work ones. On a multi-tenant app registration Entra may send no `email` claim at all, so + OpenBot falls back to `upn` and then `preferred_username`. If none of the three arrives the + sign-in is refused and the reason is logged: add `email` as an optional claim, or use your + directory GUID here. +- A half-configured provider is refused at start-up rather than at somebody's first attempt to sign + in: a client id with no secret, a secret shorter than 32 characters, or an Okta issuer with no + credentials behind it. +- **SAML and OIDC** are registered while the deployment runs rather than configured here. Sign in as + an administrator and go to Admin → Identity providers with the metadata your identity team gave + you. People then sign in by typing their email address, and the domain decides which provider + they are sent to. +- **Put TLS in front of any deployment.** A page served over plain `http://` on anything but + localhost is not a secure context, and sign-in cookies want `Secure`. ## Keeping it to your machine @@ -280,6 +315,8 @@ Use `bash scripts/start.sh` for the whole stack. Use `bun run dev` only when you - [docs/configuration.md](docs/configuration.md) - [docs/development.md](docs/development.md) - [docs/coworkers.md](docs/coworkers.md) +- [docs/deployment.md](docs/deployment.md) +- [docs/releasing.md](docs/releasing.md) ## Contributing diff --git a/app/package.json b/app/package.json index 8fd0e46..d9cf978 100644 --- a/app/package.json +++ b/app/package.json @@ -15,6 +15,7 @@ "dependencies": { "@ag-ui/core": "0.0.57", "@base-ui/react": "^1.6.0", + "@better-auth/sso": "^1.7.1", "@copilotkit/react-core": "1.68.3", "@fontsource-variable/inter": "^5.3.0", "@shadcn/react": "^0.3.0", diff --git a/app/src/components/admin/admin-sidebar.tsx b/app/src/components/admin/admin-sidebar.tsx index a5730ac..6bda384 100644 --- a/app/src/components/admin/admin-sidebar.tsx +++ b/app/src/components/admin/admin-sidebar.tsx @@ -1,5 +1,6 @@ import { IconArrowLeft, + IconBuildingBank, IconCode, IconDeviceDesktop, IconKey, @@ -8,6 +9,7 @@ import { IconPlugConnected, IconPuzzle, IconShieldCheck, + IconUsers, } from "@tabler/icons-react"; import { Link, type LinkOptions } from "@tanstack/react-router"; import type * as React from "react"; @@ -86,6 +88,21 @@ const GROUPS: { }, ], }, + { + label: "Who can get in", + items: [ + { + title: "People", + icon: IconUsers, + linkOptions: { to: "/admin/people" }, + }, + { + title: "Identity providers", + icon: IconBuildingBank, + linkOptions: { to: "/admin/identity-providers" }, + }, + ], + }, { label: "What happened", items: [ diff --git a/app/src/components/auth/provider-logo.tsx b/app/src/components/auth/provider-logo.tsx new file mode 100644 index 0000000..5af971b --- /dev/null +++ b/app/src/components/auth/provider-logo.tsx @@ -0,0 +1,96 @@ +import type { AuthProviderId } from "@/lib/auth/queries"; + +/** + * The mark each identity provider requires on a sign-in button. + * + * Drawn inline rather than fetched. These sit on the one screen somebody reaches before they have a + * session, so a mark that arrives over the network is a mark that can be missing exactly when the + * page has to be trustworthy, and a request to a third party from an unauthenticated page is a + * request nobody asked for. + * + * Reproduced at their published colours because two of the three require it. Google's guidelines say + * the standard colour G, at its own aspect ratio, neither recoloured nor restretched, and Microsoft + * publish the four squares the same way. They are trade marks used to say "this button signs you in + * with them", which is what the guidelines are for. + * + * All three are drawn into the same 18x18 box so the buttons line up. Google's G is not square, so + * it is centred in the box rather than stretched to fill it. + */ +export function ProviderLogo({ provider }: { provider: AuthProviderId }) { + if (provider === "google") return ; + if (provider === "microsoft") return ; + return ; +} + +/** Google's four-colour G, at the published path and colours. */ +function GoogleMark() { + return ( + + ); +} + +/** Microsoft's four squares, at their published colours. */ +function MicrosoftMark() { + return ( + + ); +} + +/** + * Okta's circular mark. + * + * `currentColor` rather than Okta blue, which is the one difference between this and the other two. + * Okta is not a consumer sign-in button somebody recognises by colour; it is whichever Okta the + * company running this deployment happens to use, and their guidelines allow a monochrome mark. It + * also means it stays legible in both themes without a second asset. + */ +function OktaMark() { + return ( + + ); +} diff --git a/app/src/lib/auth/client.ts b/app/src/lib/auth/client.ts index ff1f39b..ebf9866 100644 --- a/app/src/lib/auth/client.ts +++ b/app/src/lib/auth/client.ts @@ -1,14 +1,84 @@ +import { ssoClient } from "@better-auth/sso/client"; import { createAuthClient } from "better-auth/react"; +import type { AuthProviderId } from "./queries"; -export const authClient = createAuthClient(); +export const authClient = createAuthClient({ plugins: [ssoClient()] }); -export async function signInWithGoogle() { - const result = await authClient.signIn.social({ - provider: "google" as never, +/** What each provider is called on the button, since none of them are called by their id. */ +const PROVIDER_NAMES: Record = { + google: "Google", + microsoft: "Microsoft", + okta: "Okta", +}; + +export function providerName(provider: AuthProviderId): string { + return PROVIDER_NAMES[provider]; +} + +/** What a sign-in attempt came back with, which is either nothing or a reason. */ +type SocialResult = { error?: { message?: string } | null }; + +/** + * Start sign-in with one provider. + * + * One call for all three, including Okta. Okta is served by the generic OAuth plugin rather than as + * a named provider, but the plugin registers under a provider id like any other, so the browser does + * not need to know which kind it is asking for. Keeping that distinction on the server is the point: + * a deployment can gain a provider without the app being rebuilt. + * + * `start` is injectable because Better Auth's client is a proxy, so a test cannot replace the method + * on it. Named so it cannot shadow anything it defaults to. + */ +export async function signInWith( + provider: AuthProviderId, + start: (input: { + provider: string; + callbackURL: string; + }) => Promise = (input) => + authClient.signIn.social(input as never) as Promise, +) { + const result = await start({ + provider, callbackURL: window.location.origin, }); if (result.error) { - throw new Error(result.error.message ?? "Could not start Google sign-in."); + // Naming the provider matters more with three buttons than it did with one: "Could not start + // sign-in" leaves somebody looking at three of them with no idea which one refused. + throw new Error( + result.error.message || + `Could not start ${providerName(provider)} sign-in.`, + ); + } +} + +/** + * Start sign-in through whichever identity provider covers this address. + * + * The email is not a credential here and no password is asked for: only the part after the @ is + * used, to decide which registered provider to hand somebody to. A company with two IdPs mid-merger + * has two domains, and this is how somebody reaches theirs without being asked which one they are. + * + * Injectable for the same reason as `signInWith`: the Better Auth client is a proxy. + */ +export async function signInWithEmailDomain( + email: string, + start: (input: { + email: string; + callbackURL: string; + }) => Promise = (input) => + ( + authClient as unknown as { + signIn: { sso: (i: unknown) => Promise }; + } + ).signIn.sso(input), +) { + const result = await start({ email, callbackURL: window.location.origin }); + + if (result.error) { + throw new Error( + result.error.message || + "No identity provider is registered for that address.", + ); } } diff --git a/app/src/lib/auth/queries.ts b/app/src/lib/auth/queries.ts index aee0db5..6c4ded5 100644 --- a/app/src/lib/auth/queries.ts +++ b/app/src/lib/auth/queries.ts @@ -1,5 +1,5 @@ import { queryOptions } from "@tanstack/react-query"; -import { tryClient } from "@/lib/client"; +import { client, tryClient } from "@/lib/client"; export type AuthenticatedUser = { id: string; @@ -12,8 +12,53 @@ export type AuthenticatedUser = { export const authKeys = { all: ["auth"] as const, currentUser: () => [...authKeys.all, "current-user"] as const, + providers: () => [...authKeys.all, "providers"] as const, }; +/** An identity provider this deployment can sign somebody in with. */ +export type AuthProviderId = "google" | "microsoft" | "okta"; + +/** What the sign-in screen may offer, answered by the process that knows. */ +export type SignInOptions = { + providers: AuthProviderId[]; + /** + * Whether any enterprise identity provider is registered. + * + * A boolean, not a list: naming them would tell anybody who loads the sign-in page which companies + * use this deployment, before they have signed in. + */ + sso: boolean; +}; + +async function signInOptions(): Promise { + // The whole body, so both fields arrive together. Reading a field off the Response `client` + // returns without a key quietly yields undefined: the screen would say no provider is configured + // while the server was saying it has one. + const body = (await ( + await client("/api/capabilities", { fallback: "Could not load sign-in" }) + ).json()) as { authProviders?: AuthProviderId[]; ssoConfigured?: boolean }; + + return { + providers: body.authProviders ?? [], + sso: body.ssoConfigured === true, + }; +} + +/** + * Which providers the sign-in screen may offer. + * + * From the server rather than from the build. The image is built once with no deployment + * environment, so a list compiled into the bundle can only ever describe the build machine. + */ +export function authProvidersQueryOptions() { + return queryOptions({ + queryKey: authKeys.providers(), + queryFn: signInOptions, + // Configuration, not data. It cannot change without the process restarting. + staleTime: Number.POSITIVE_INFINITY, + }); +} + async function currentUser(): Promise { /* * `tryClient` rather than `client`: not being signed in is an answer here, not a failure, and it diff --git a/app/src/lib/identity-providers/mutations.ts b/app/src/lib/identity-providers/mutations.ts new file mode 100644 index 0000000..fec6cf3 --- /dev/null +++ b/app/src/lib/identity-providers/mutations.ts @@ -0,0 +1,94 @@ +import { mutationOptions, type QueryClient } from "@tanstack/react-query"; +import { client } from "@/lib/client"; +import { identityProviderKeys } from "./queries"; + +const FALLBACK = "Could not change that identity provider"; + +function invalidateProviders(queryClient: QueryClient) { + return queryClient.invalidateQueries({ queryKey: identityProviderKeys.all }); +} + +/** + * What an administrator has to supply to register an identity provider. + * + * SAML wants the metadata XML their identity team gives them, which carries the entry point and the + * signing certificate. OIDC wants an issuer and a client, because there is no equivalent document to + * paste. Both want the domain, since that is what routes somebody to this provider rather than + * another. + */ +export type IdentityProviderInput = + | { + protocol: "saml"; + providerId: string; + domain: string; + issuer: string; + entryPoint: string; + metadata: string; + } + | { + protocol: "oidc"; + providerId: string; + domain: string; + issuer: string; + clientId: string; + clientSecret: string; + }; + +/** The body Better Auth's own route expects, which is not the shape the form collects. */ +function registerBody(input: IdentityProviderInput) { + const common = { + providerId: input.providerId, + issuer: input.issuer, + domain: input.domain, + }; + + if (input.protocol === "saml") { + return { + ...common, + samlConfig: { + entryPoint: input.entryPoint, + idpMetadata: { metadata: input.metadata }, + }, + }; + } + + return { + ...common, + oidcConfig: { + clientId: input.clientId, + clientSecret: input.clientSecret, + // Let the provider describe itself rather than asking somebody to type six endpoints. + discoveryEndpoint: `${input.issuer.replace(/\/$/, "")}/.well-known/openid-configuration`, + }, + }; +} + +export function registerIdentityProviderMutationOptions( + queryClient: QueryClient, +) { + return mutationOptions({ + mutationFn: async (input: IdentityProviderInput): Promise => { + await client("/api/auth/sso/register", { + method: "POST", + body: registerBody(input), + fallback: FALLBACK, + }); + }, + onSuccess: () => invalidateProviders(queryClient), + }); +} + +export function deleteIdentityProviderMutationOptions( + queryClient: QueryClient, +) { + return mutationOptions({ + mutationFn: async (providerId: string): Promise => { + await client("/api/auth/sso/delete-provider", { + method: "POST", + body: { providerId }, + fallback: FALLBACK, + }); + }, + onSuccess: () => invalidateProviders(queryClient), + }); +} diff --git a/app/src/lib/identity-providers/queries.ts b/app/src/lib/identity-providers/queries.ts new file mode 100644 index 0000000..1c9e3b3 --- /dev/null +++ b/app/src/lib/identity-providers/queries.ts @@ -0,0 +1,58 @@ +import { queryOptions } from "@tanstack/react-query"; +import { client } from "@/lib/client"; + +/** + * An identity provider a company registered, rather than one this deployment was configured with. + * + * The three in the environment are Google, Entra and Okta. These are somebody's own: registered + * while the deployment is running, from metadata their identity team supplied, and there can be + * several. A company mid-merger has two. + */ +export type IdentityProvider = { + providerId: string; + issuer: string; + /** The email domain that routes somebody here. */ + domain: string; + /** Which protocol it speaks. SAML is what most enterprise identity teams hand over. */ + protocol: "saml" | "oidc"; +}; + +export const identityProviderKeys = { + all: ["identity-providers"] as const, + list: () => ["identity-providers", "list"] as const, +}; + +/** + * The registered providers. + * + * Read from Better Auth's own route rather than one of ours, because the plugin owns the table and a + * second reader would be a second answer. The payload carries no client secret or signing key: the + * fields below are all this asks for. + */ +export function identityProviderListQueryOptions() { + return queryOptions({ + queryKey: identityProviderKeys.list(), + queryFn: async (): Promise => { + // `{ providers: [...] }`, not a bare array. Better Auth's own routes carry their own + // envelope, which is why this reads the body rather than passing a key to `client`. + const response = await client("/api/auth/sso/providers", { + fallback: "Could not load identity providers", + }); + const { providers = [] } = (await response.json()) as { + providers?: { + providerId: string; + issuer: string; + domain: string; + samlConfig?: unknown; + }[]; + }; + + return providers.map((provider) => ({ + providerId: provider.providerId, + issuer: provider.issuer, + domain: provider.domain, + protocol: provider.samlConfig ? "saml" : "oidc", + })); + }, + }); +} diff --git a/app/src/lib/people/mutations.ts b/app/src/lib/people/mutations.ts new file mode 100644 index 0000000..0a5721b --- /dev/null +++ b/app/src/lib/people/mutations.ts @@ -0,0 +1,45 @@ +import { mutationOptions, type QueryClient } from "@tanstack/react-query"; +import { client } from "@/lib/client"; +import { type Person, peopleKeys } from "./queries"; + +const FALLBACK = "Could not update that person"; + +function invalidatePeople(queryClient: QueryClient) { + return queryClient.invalidateQueries({ queryKey: peopleKeys.all }); +} + +export function setPersonRoleMutationOptions(queryClient: QueryClient) { + return mutationOptions({ + mutationFn: (variables: { + userId: string; + role: "admin" | "user"; + }): Promise => + client(`/api/admin/people/${variables.userId}/role`, "person", { + method: "POST", + body: { role: variables.role }, + fallback: FALLBACK, + }), + onSuccess: () => invalidatePeople(queryClient), + }); +} + +/** + * Remove somebody's access, or give it back. + * + * One mutation rather than two, because the row is a single decision with two directions and the + * screen renders the same control either way. + */ +export function setPersonAccessMutationOptions(queryClient: QueryClient) { + return mutationOptions({ + mutationFn: (variables: { + userId: string; + revoked: boolean; + }): Promise => + client(`/api/admin/people/${variables.userId}/access`, "person", { + method: "POST", + body: { revoked: variables.revoked }, + fallback: FALLBACK, + }), + onSuccess: () => invalidatePeople(queryClient), + }); +} diff --git a/app/src/lib/people/queries.ts b/app/src/lib/people/queries.ts new file mode 100644 index 0000000..7fff1c2 --- /dev/null +++ b/app/src/lib/people/queries.ts @@ -0,0 +1,43 @@ +import { queryOptions } from "@tanstack/react-query"; +import { client } from "@/lib/client"; + +/** + * Somebody who has signed in to this deployment. + * + * People are here because they signed in, not because they were invited: the identity provider + * decides who exists, and this screen decides what they may do once they are here. + */ +export type Person = { + id: string; + email: string; + name: string | null; + image: string | null; + role: "admin" | "user"; + /** The providers they have arrived through. More than one is normal mid-migration. */ + providers: string[]; + lastSignedInAt: string | null; + /** Whether an administrator has removed them. They keep their row and their history. */ + revoked: boolean; + /** + * Whether the deployment's configuration fixes their role. + * + * The server's verdict, rendered rather than recomputed here: this screen does not know what is in + * `INITIAL_ADMIN_EMAILS` and should not try to work it out from anything else. + */ + configuredAdmin: boolean; +}; + +export const peopleKeys = { + all: ["people"] as const, + list: () => ["people", "list"] as const, +}; + +export function peopleListQueryOptions() { + return queryOptions({ + queryKey: peopleKeys.list(), + queryFn: (): Promise => + client("/api/admin/people", "people", { + fallback: "Could not load people", + }), + }); +} diff --git a/app/src/routeTree.gen.ts b/app/src/routeTree.gen.ts index 48a0bf9..c7fae13 100644 --- a/app/src/routeTree.gen.ts +++ b/app/src/routeTree.gen.ts @@ -23,6 +23,8 @@ import { Route as AuthedAdminBoundariesRouteImport } from './routes/_authed/admi import { Route as AuthedAdminComputersRouteImport } from './routes/_authed/admin/computers' import { Route as AuthedAdminConnectorsRouteImport } from './routes/_authed/admin/connectors' import { Route as AuthedAdminCredentialsRouteImport } from './routes/_authed/admin/credentials' +import { Route as AuthedAdminIdentityProvidersRouteImport } from './routes/_authed/admin/identity-providers' +import { Route as AuthedAdminPeopleRouteImport } from './routes/_authed/admin/people' import { Route as AuthedAdminPlaygroundRouteImport } from './routes/_authed/admin/playground' import { Route as AuthedAdminPluginsRouteImport } from './routes/_authed/admin/plugins' import { Route as AuthedSettingsIndexRouteImport } from './routes/_authed/settings/index' @@ -103,6 +105,17 @@ const AuthedAdminCredentialsRoute = AuthedAdminCredentialsRouteImport.update({ path: '/credentials', getParentRoute: () => AuthedAdminRouteRoute, } as any) +const AuthedAdminIdentityProvidersRoute = + AuthedAdminIdentityProvidersRouteImport.update({ + id: '/identity-providers', + path: '/identity-providers', + getParentRoute: () => AuthedAdminRouteRoute, + } as any) +const AuthedAdminPeopleRoute = AuthedAdminPeopleRouteImport.update({ + id: '/people', + path: '/people', + getParentRoute: () => AuthedAdminRouteRoute, +} as any) const AuthedAdminPlaygroundRoute = AuthedAdminPlaygroundRouteImport.update({ id: '/playground', path: '/playground', @@ -177,6 +190,8 @@ export interface FileRoutesByFullPath { '/admin/computers': typeof AuthedAdminComputersRoute '/admin/connectors': typeof AuthedAdminConnectorsRouteWithChildren '/admin/credentials': typeof AuthedAdminCredentialsRoute + '/admin/identity-providers': typeof AuthedAdminIdentityProvidersRoute + '/admin/people': typeof AuthedAdminPeopleRoute '/admin/playground': typeof AuthedAdminPlaygroundRoute '/admin/plugins': typeof AuthedAdminPluginsRoute '/admin/': typeof AuthedAdminIndexRoute @@ -200,6 +215,8 @@ export interface FileRoutesByTo { '/admin/computers': typeof AuthedAdminComputersRoute '/admin/connectors': typeof AuthedAdminConnectorsRouteWithChildren '/admin/credentials': typeof AuthedAdminCredentialsRoute + '/admin/identity-providers': typeof AuthedAdminIdentityProvidersRoute + '/admin/people': typeof AuthedAdminPeopleRoute '/admin/playground': typeof AuthedAdminPlaygroundRoute '/admin/plugins': typeof AuthedAdminPluginsRoute '/admin': typeof AuthedAdminIndexRoute @@ -227,6 +244,8 @@ export interface FileRoutesById { '/_authed/admin/computers': typeof AuthedAdminComputersRoute '/_authed/admin/connectors': typeof AuthedAdminConnectorsRouteWithChildren '/_authed/admin/credentials': typeof AuthedAdminCredentialsRoute + '/_authed/admin/identity-providers': typeof AuthedAdminIdentityProvidersRoute + '/_authed/admin/people': typeof AuthedAdminPeopleRoute '/_authed/admin/playground': typeof AuthedAdminPlaygroundRoute '/_authed/admin/plugins': typeof AuthedAdminPluginsRoute '/_authed/_app/': typeof AuthedAppIndexRoute @@ -255,6 +274,8 @@ export interface FileRouteTypes { | '/admin/computers' | '/admin/connectors' | '/admin/credentials' + | '/admin/identity-providers' + | '/admin/people' | '/admin/playground' | '/admin/plugins' | '/admin/' @@ -278,6 +299,8 @@ export interface FileRouteTypes { | '/admin/computers' | '/admin/connectors' | '/admin/credentials' + | '/admin/identity-providers' + | '/admin/people' | '/admin/playground' | '/admin/plugins' | '/admin' @@ -304,6 +327,8 @@ export interface FileRouteTypes { | '/_authed/admin/computers' | '/_authed/admin/connectors' | '/_authed/admin/credentials' + | '/_authed/admin/identity-providers' + | '/_authed/admin/people' | '/_authed/admin/playground' | '/_authed/admin/plugins' | '/_authed/_app/' @@ -424,6 +449,20 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AuthedAdminCredentialsRouteImport parentRoute: typeof AuthedAdminRouteRoute } + '/_authed/admin/identity-providers': { + id: '/_authed/admin/identity-providers' + path: '/identity-providers' + fullPath: '/admin/identity-providers' + preLoaderRoute: typeof AuthedAdminIdentityProvidersRouteImport + parentRoute: typeof AuthedAdminRouteRoute + } + '/_authed/admin/people': { + id: '/_authed/admin/people' + path: '/people' + fullPath: '/admin/people' + preLoaderRoute: typeof AuthedAdminPeopleRouteImport + parentRoute: typeof AuthedAdminRouteRoute + } '/_authed/admin/playground': { id: '/_authed/admin/playground' path: '/playground' @@ -523,6 +562,8 @@ interface AuthedAdminRouteRouteChildren { AuthedAdminComputersRoute: typeof AuthedAdminComputersRoute AuthedAdminConnectorsRoute: typeof AuthedAdminConnectorsRouteWithChildren AuthedAdminCredentialsRoute: typeof AuthedAdminCredentialsRoute + AuthedAdminIdentityProvidersRoute: typeof AuthedAdminIdentityProvidersRoute + AuthedAdminPeopleRoute: typeof AuthedAdminPeopleRoute AuthedAdminPlaygroundRoute: typeof AuthedAdminPlaygroundRoute AuthedAdminPluginsRoute: typeof AuthedAdminPluginsRoute AuthedAdminIndexRoute: typeof AuthedAdminIndexRoute @@ -536,6 +577,8 @@ const AuthedAdminRouteRouteChildren: AuthedAdminRouteRouteChildren = { AuthedAdminComputersRoute: AuthedAdminComputersRoute, AuthedAdminConnectorsRoute: AuthedAdminConnectorsRouteWithChildren, AuthedAdminCredentialsRoute: AuthedAdminCredentialsRoute, + AuthedAdminIdentityProvidersRoute: AuthedAdminIdentityProvidersRoute, + AuthedAdminPeopleRoute: AuthedAdminPeopleRoute, AuthedAdminPlaygroundRoute: AuthedAdminPlaygroundRoute, AuthedAdminPluginsRoute: AuthedAdminPluginsRoute, AuthedAdminIndexRoute: AuthedAdminIndexRoute, diff --git a/app/src/routes/_authed/admin/identity-providers.tsx b/app/src/routes/_authed/admin/identity-providers.tsx new file mode 100644 index 0000000..64f2123 --- /dev/null +++ b/app/src/routes/_authed/admin/identity-providers.tsx @@ -0,0 +1,308 @@ +import { IconBuildingBank, IconTrash } from "@tabler/icons-react"; +import { useMutation, useQuery } from "@tanstack/react-query"; +import { createFileRoute } from "@tanstack/react-router"; +import { useState } from "react"; +import { + PageEmpty, + PageRows, + PageSection, + PageShell, +} from "@/components/layout/page-shell"; +import { StaggerItem } from "@/components/layout/stagger"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogBody, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { + Item, + ItemActions, + ItemContent, + ItemDescription, + ItemMedia, + ItemTitle, +} from "@/components/ui/item"; +import { Label } from "@/components/ui/label"; +import { Separator } from "@/components/ui/separator"; +import { Textarea } from "@/components/ui/textarea"; +import { + deleteIdentityProviderMutationOptions, + type IdentityProviderInput, + registerIdentityProviderMutationOptions, +} from "@/lib/identity-providers/mutations"; +import { identityProviderListQueryOptions } from "@/lib/identity-providers/queries"; +import { queryClient } from "@/query-client"; + +export const Route = createFileRoute("/_authed/admin/identity-providers")({ + component: IdentityProvidersPage, +}); + +const EMPTY = { + protocol: "saml" as "saml" | "oidc", + providerId: "", + domain: "", + issuer: "", + entryPoint: "", + metadata: "", + clientId: "", + clientSecret: "", +}; + +function IdentityProvidersPage() { + const providers = useQuery(identityProviderListQueryOptions()); + const register = useMutation( + registerIdentityProviderMutationOptions(queryClient), + ); + const remove = useMutation( + deleteIdentityProviderMutationOptions(queryClient), + ); + const [open, setOpen] = useState(false); + const [draft, setDraft] = useState(EMPTY); + + const failure = register.error ?? remove.error; + + function submit(submission: React.FormEvent) { + submission.preventDefault(); + const input: IdentityProviderInput = + draft.protocol === "saml" + ? { + protocol: "saml", + providerId: draft.providerId, + domain: draft.domain, + issuer: draft.issuer, + entryPoint: draft.entryPoint, + metadata: draft.metadata, + } + : { + protocol: "oidc", + providerId: draft.providerId, + domain: draft.domain, + issuer: draft.issuer, + clientId: draft.clientId, + clientSecret: draft.clientSecret, + }; + + register.mutate(input, { + onSuccess: () => { + setDraft(EMPTY); + setOpen(false); + }, + }); + } + + return ( + setOpen(true)} size="lg"> + Add a provider + + } + description="A company's own identity provider, by SAML or OpenID Connect. Somebody types their email address and the domain decides which one they are sent to." + title="Identity providers" + > + + {failure ? ( +

+ {failure.message} +

+ ) : null} + {providers.isPending ? null : providers.error ? ( +

+ Could not load identity providers. +

+ ) : providers.data?.length === 0 ? ( + + No identity providers are registered. Add one with the metadata your + identity team supplied. + + ) : ( + + {providers.data?.map((provider, index) => ( + + + + + + + {provider.providerId} + + {provider.protocol.toUpperCase()} · {provider.domain} ·{" "} + {provider.issuer} + + + + + + + {index !== (providers.data?.length ?? 0) - 1 && } + + ))} + + )} +
+ + + + + Add an identity provider + +
+ +
+ {(["saml", "oidc"] as const).map((protocol) => ( + + ))} +
+ +
+ + + setDraft({ ...draft, providerId: event.target.value }) + } + placeholder="acme-okta" + required + value={draft.providerId} + /> +
+ +
+ + + setDraft({ ...draft, domain: event.target.value }) + } + placeholder="acme.com" + required + value={draft.domain} + /> + {/* Said out loud because it is the field that decides who this applies to. */} +

+ Anybody signing in with an address at this domain is sent + here. Separate several with commas. +

+
+ +
+ + + setDraft({ ...draft, issuer: event.target.value }) + } + placeholder="https://acme.okta.com" + required + type="url" + value={draft.issuer} + /> +
+ + {draft.protocol === "saml" ? ( + <> +
+ + + setDraft({ ...draft, entryPoint: event.target.value }) + } + placeholder="https://acme.okta.com/app/.../sso/saml" + required + type="url" + value={draft.entryPoint} + /> +
+
+ +