Skip to content

Commit f725fb5

Browse files
authored
Sign in for real, and decide who gets in (#67)
* Sign in with Google, Microsoft or Okta, whichever a deployment has One identity provider was a decision somebody else already made. A company running this has Google or Entra or Okta and is not going to acquire another, so any one of the three turns sign-in on, several turn on several, and the sign-in screen draws a button per provider in a fixed order. Google and Entra are named providers Better Auth knows the endpoints of. Okta is not one place, so it goes through the generic OAuth plugin against its issuer, and the plugin is only registered when Okta is configured. They converge at the browser: one `signIn.social({ provider })` for all three, so the app does not know which kind it is asking for and a deployment can gain one without a rebuild. The provider list moved from the build to `/api/capabilities`. It used to be compiled into the bundle from the build machine's environment, which was survivable until the container: one image, built once, knowing nothing about the deployment that runs it, would have offered a sign-in screen that had never heard of the provider the operator configured. Nothing configured now means one administrator without a flag, so a fresh clone reaches the product without registering an OAuth client first. The lock moved from a flag to `NODE_ENV`: somewhere other people can reach, an unconfigured deployment refuses to start and names what to configure, because a public URL where every visitor is an administrator is silent and looks like it works. `OPENBOT_SINGLE_USER=true` is how somebody says they meant it. Two defects found by signing in for real rather than reading the code. Better Auth 1.7 requires an `issuer` on every account and this schema, written against 1.6, had no such column. The adapter rendered `where ( = $1 ...)` with an empty column name and the callback failed with an internal error. Migration 0002 adds it as three statements rather than the one Drizzle generates, because `ADD COLUMN ... NOT NULL` with no default fails outright on a table that already has rows, and Google's rows are backfilled with Google's real issuer so they still match at the next sign-in. `server/package.json` also asked for `^1.6.27` while 1.7.1 was what resolved, leaving three copies of the adapter installed. Pinned to what actually runs. * Make the administrator list mean something after the first sign-in Two ways a deployment could end up with nobody who can administer it, and no way back from either. `INITIAL_ADMIN_EMAILS` was optional. Configure sign-in without it and everybody arrives as a plain user, nobody sees the admin screens, and nobody can promote anyone, because the role is written from that list and no route anywhere changes one. `.env.example` ships it commented out, so copying the example and adding a provider was enough to do it. Sign-in now refuses to start without it. The role was also written once, in the create hook. Adding yourself to the list after you had already signed in did nothing at all: the row said `user`, for ever. It is now reconciled on every sign-in, which also means an address taken off the list loses `admin` next time it signs in. `user_roles` is a set and the guard takes `admin` if any row says so, so reconciling deletes the rows that should not be there rather than only inserting one, both inside a transaction: between the two a request on another process would find no role at all and be refused with a 403 that reads as a permissions bug. Driven on the real path rather than reasoned about: the same account went admin, then user with the address removed, then admin again with it restored. The middle step is what the old hook could not do. * Make the administrator list a floor, and put each provider's mark on its button The list and an admin screen have to be able to disagree without one silently undoing the other. So `INITIAL_ADMIN_EMAILS` is a floor: an address it names is made an administrator at every sign-in and cannot be demoted, which is the way back in when the last administrator demotes themselves by accident. Everybody else is left exactly as they are, because their role is the admin screen's to decide and a sign-in that rewrote it would make that screen lie the moment they came back. That is a change from an hour ago, when sign-in rewrote every role from the list and would have reverted any promotion made in a screen that does not exist yet. The buttons now carry each provider's own mark, drawn inline rather than fetched: this is the one page somebody reaches before they have a session, so a mark that arrives over the network is one that can be missing exactly when the page has to look trustworthy, and it asks nothing of a third party from an unauthenticated page. Google's guidelines require the standard colour G at its own aspect ratio and require their button be at least as prominent as any other sign-in option, so all three are the same size and weight and none of them is the loud one. Okta's is monochrome, which their guidelines allow: it is not a consumer button anybody recognises by colour, it is whichever Okta the company uses, and it stays legible in both themes without a second asset. * Let an administrator decide who else is one An environment variable was the only way to grant the administrator role, and no route anywhere changed one. That is not how a company runs a deployment: the people who need access arrive after the deployment does. So a People screen. Everybody who has signed in, the providers they came through, when they were last here, and two decisions per row. Removing somebody is both halves or it is theatre. The deny list stops the next sign-in and deleting their sessions stops the current one, because otherwise a removed person keeps working until their cookie happens to expire, which can be days. It is keyed on the email address rather than the user id: deleting the row is not removal, since the next sign-in through the provider creates it again with a fresh id and no memory of having been removed. Three refusals, all enforced on the server and only mirrored in the browser. Nobody may demote themselves or remove their own access, because either locks them out of the screen that would undo it, and on a deployment with one administrator that is the whole deployment. And somebody named in INITIAL_ADMIN_EMAILS may be neither, because the floor promotes them again at their next sign-in and the screen would be lying until then. Every change writes a row. The table holds the current answer; the trail is the only thing that can say who changed it and when. Found by driving it: people who had never signed in sorted above people who just had, because Postgres puts nulls first on a descending order. On a real deployment that is the whole first screen given to people who have never used it. * Take a company's own identity provider, by SAML or OIDC The three configured providers cover a company that uses Google, Entra or Okta. They do not cover a company that runs its own identity provider, which is most of the ones that ask, and which cannot be configured up front because the deployment is built before it knows whose IdP it will trust. So they are registered while running. An administrator pastes the metadata their identity team supplied and the provider is stored against an email domain. Somebody signing in types their address, and the part after the @ decides which provider they are handed to, so a company mid-merger can run two at once. No password is asked for and none is checked here. Registering, changing and removing one is administrator-only. Better Auth guards those routes with `sessionMiddleware`, which asks only that somebody is signed in, and that is the wrong bar: registering a provider for a domain means anybody it vouches for can sign in, so a plain user reaching it could mint themselves colleagues. The gate sits in front of the handler and is tested. The sign-in screen grows the email box only when a provider is registered, and the capability that says so is a boolean rather than a list: naming them would tell anybody who loads the page which companies use this deployment. Driven end to end. A registered SAML provider produces a real signed SAMLRequest redirect for an address at its domain and a 404 for one that is not, the same delete call answers 403 signed out and 200 as an administrator, and the sign-in screen adds and drops the email box as the last provider comes and goes. * Find an address for somebody arriving from Entra, whatever claim it is in The sign-in flow really is the same for all three: authorization code with PKCE, discovery, an ID token. Google and Entra run through the same function. The claims inside that token are where they stop agreeing. Entra does not always send `email`. Microsoft return it only when the profile carries an email attribute, and a multi-tenant application may receive no optional claims at all, because an external user's token is minted by their own tenant and does not inherit this application's claim configuration. `common`, the default tenant here, is multi-tenant. Better Auth maps `email` straight through with no fallback, so on those deployments it arrives undefined. That is worse here than in most products, because every authorization decision OpenBot makes about a person is keyed on their address: INITIAL_ADMIN_EMAILS, the role, the deny list and the People screen all read it. Somebody would sign in successfully, match no administrator, and land as a plain user with nothing on any screen explaining why. So `upn` first, then `preferred_username`, and only if it looks like an address: the OIDC spec explicitly does not promise that claim is one. If none of the three is there, nothing is returned and Better Auth refuses the sign-in, which is a better answer than quietly admitting somebody the deployment cannot recognise. The reason is logged with the claims that did arrive. Found by reading the provider Microsoft-side rather than by testing, since there are no Entra credentials here yet. * Generate the schema steps, and write only the data step The issuer migration was one file I had edited by hand after Drizzle generated it, because the generated `ADD COLUMN ... NOT NULL` fails outright on a table that already has rows. Editing a generated file is the wrong fix: it leaves a file that no longer matches what the generator produced. It is three steps instead, and only the middle one is written: 0002 generated the column, nullable, and the two new tables 0003 custom the backfill 0004 generated the column made required `drizzle-kit generate --custom` is Drizzle's own mechanism for this, and their documentation names data seeding as the reason it exists. A generator diffs schema against schema, so "the rows whose provider is Google get Google's issuer" cannot come out of one: it is not in the schema. The generatable alternative is a column default, and it is wrong rather than merely inelegant. Every existing Google account would take the placeholder, stop matching `https://accounts.google.com` at that person's next sign-in, and Better Auth would create them a second account. Driven both ways with `drizzle-kit migrate` itself rather than by hand: from empty, and against a database already holding Google, credential and Microsoft accounts, where the three rows come out with Google's real issuer and the synthetic form for the rest. Worth knowing for the check that landed in #64: `drizzle-kit check` reports "Everything's fine" when a journal entry names a migration file that does not exist, which is a state a rebase can produce. It cost an hour here. The drift probe does not catch it either, since both look at schemas rather than at whether the journal and the directory agree. * Say what sign-in does, everywhere it is documented The configuration reference still described Google as the only provider and described `INITIAL_ADMIN_EMAILS` as optional, which is now a start-up failure. It carries all three providers, what each needs, the callback URL to register, and why the administrator list is required. The architecture notes gain the parts a reader cannot infer from the code: that one resolver answers both questions a run asks about a person, that the configured list is a floor rather than a one-off, that registering an identity provider is administrator-only where the upstream plugin asks only for a session, and that removing somebody denies the address rather than deleting the row, since deleting it is not removal. Two lines in the README's feature list, because sign-in and deciding who gets in are now things the product does rather than things it lacks. The generated Drizzle snapshots are formatted, which is what the committed ones already were: `drizzle-kit generate` writes them without a trailing newline and the format check refuses that. * Bring the docs up to what is actually merged Audited every markdown file against everything that landed today, including the work that was not mine. `docs/coworkers.md` still told people to point `MANAGED_AGENT_AG_UI_URL` at `4200`. #33 made `agent-langgraph` on `4201` the default precisely because the proof-of-concept hand-writes the protocol and leaves the tool loop to whatever is watching, so following that page produced the shape the change moved away from. Three environment variables the server reads were in `.env.example` and nowhere in the configuration reference: `AGENT_STALL_TIMEOUT_MS` from #19, which is the only thing that notices a Bot's stream going silent; `AGENT_TOOL_TOKEN` from #34, without which no framework Bot may call a granted tool back; and `APP_DIST_DIR`, which the container sets so one process serves both halves. Both documentation indexes had fallen behind their own directory and listed neither `deployment.md` nor `releasing.md`. `docs/development.md` gains the migration workflow the checks in #64 now enforce: never hand-edit a generated migration, write a data step with `--custom`, and what to do when `drizzle-kit migrate` hangs and exits non-zero with nothing printed, which is the journal naming a file a rebase renamed. `drizzle-kit check` calls that state fine, because it compares schemas rather than asking whether the journal and the directory agree. The README keeps its shape: what this is, how to run it, how to deploy it, and where to read the rest. * Tell the image check it meant to run without sign-in The check boots the container with no identity provider, and the image sets NODE_ENV=production, where that combination now refuses to start rather than serve a deployment on which every visitor is an administrator. So the check has to declare it, which is what the flag is for. It was passing `OPENBOT_DEV_NO_AUTH=1`, which the code has never accepted: both the old flag and the new one compare against the exact string "true". It did nothing, and nothing noticed, because before this branch a deployment with no provider still started and answered on an unauthenticated route. The refusal turned a silent no-op into a visible failure, which is the check working. Reproduced locally with the same command the job runs: answers on /api/capabilities in four seconds, nothing respawning after fifteen, and the `eventsource` import error that appeared in the failing log is absent, since it was the crash loop rather than a fault of its own. * Put the upgrade note where somebody upgrading will find it Two configurations that start today refuse to after this, and both were buried mid-paragraph in Added and Changed. They are four lines at the top of Unreleased now, saying what to set rather than what used to happen. Rebased onto #68, which took the deployment's environment away from a Bot's shell. Checked on the running computer rather than trusting the tests: `GOOGLE_OAUTH_CLIENT_SECRET`, which this branch introduces, is absent from a command's environment without anybody having added it to a list. That is the allowlist earning its shape.
1 parent d90ff63 commit f725fb5

52 files changed

Lines changed: 11159 additions & 173 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.env.example

Lines changed: 30 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -11,21 +11,41 @@ TENANT_PACKAGE_DIR=../examples/fintech
1111
# deployment mints, so its own conversations stay identifiable. Unset, the tenant package's id is
1212
# used, which tells two packages apart but not two copies of one.
1313
# DEPLOYMENT_ID=
14-
# Google sign-in. Leave commented for local development with OPENBOT_DEV_NO_AUTH; uncomment all five
15-
# settings together so authentication is either fully configured or absent.
16-
#
17-
# BETTER_AUTH_SECRET must be a high-entropy secret of at least 32 characters. Generate one:
18-
# openssl rand -base64 32
19-
# TRUSTED_ORIGINS is where the app is served from, which is port 3010 locally.
14+
# Sign-in. All of this is commented out, and a clone with none of it set is one administrator with
15+
# no sign-in at all, which is how you reach the product without registering an OAuth client first.
16+
# Somewhere other people can get to, an unconfigured deployment refuses to start rather than serving
17+
# an open one. OPENBOT_SINGLE_USER=true says you meant it.
18+
#
19+
# Configure ANY ONE of the three providers to turn sign-in on. Configure several and the sign-in
20+
# screen offers several, which is the normal shape for a company mid-migration.
21+
#
22+
# These four are needed whichever provider you pick:
23+
# BETTER_AUTH_URL is where OAuth callbacks come back to, which is the API on port 3001.
24+
# BETTER_AUTH_SECRET signs session cookies. At least 32 characters: openssl rand -base64 32
25+
# TRUSTED_ORIGINS is where the app is served from, which is port 3010 locally.
26+
# INITIAL_ADMIN_EMAILS names who is an administrator. Required, because nothing else grants the
27+
# role and no screen can promote somebody later. Re-read on every sign-in, so editing it works.
2028
# BETTER_AUTH_URL=http://localhost:3001
2129
# BETTER_AUTH_SECRET=
30+
# INITIAL_ADMIN_EMAILS=admin@example.com
31+
#
32+
# Google. Redirect URI: http://localhost:3001/api/auth/callback/google
2233
# GOOGLE_OAUTH_CLIENT_ID=
2334
# GOOGLE_OAUTH_CLIENT_SECRET=
24-
# INITIAL_ADMIN_EMAILS=admin@example.com
35+
#
36+
# Microsoft (Entra ID). Redirect URI: http://localhost:3001/api/auth/callback/microsoft
37+
# MICROSOFT_OAUTH_TENANT_ID defaults to `common`, which admits personal Microsoft accounts as well
38+
# as work ones. Put your directory GUID here if you mean only your own company.
39+
# MICROSOFT_OAUTH_CLIENT_ID=
40+
# MICROSOFT_OAUTH_CLIENT_SECRET=
41+
# MICROSOFT_OAUTH_TENANT_ID=common
42+
#
43+
# Okta. Redirect URI: http://localhost:3001/api/auth/callback/okta
44+
# The issuer is what makes it your Okta rather than Okta in general.
45+
# OKTA_OAUTH_CLIENT_ID=
46+
# OKTA_OAUTH_CLIENT_SECRET=
47+
# OKTA_OAUTH_ISSUER=https://example.okta.com/oauth2/default
2548

26-
# Local development only. This admits every request as one local administrator. Keep it explicit and
27-
# never expose a deployment to the internet in this state; production refuses to start with it.
28-
OPENBOT_DEV_NO_AUTH=true
2949
TRUSTED_ORIGINS=http://localhost:3010
3050

3151
# CopilotKit Intelligence. Required: the server refuses to start without all four because

.github/workflows/ci.yml

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,12 @@ jobs:
145145
- name: The image boots and serves
146146
run: |
147147
set -euo pipefail
148+
# `OPENBOT_SINGLE_USER=true` because this boots a deployment with no identity provider, and
149+
# the image sets NODE_ENV=production, where that combination refuses to start rather than
150+
# serve an open deployment. Saying so is the point: the flag is how somebody declares they
151+
# meant it. This was `OPENBOT_DEV_NO_AUTH=1` before, which the code never accepted at all,
152+
# since it compares against the exact string "true"; it did nothing and nothing noticed.
153+
#
148154
# Placeholders, not secrets. `loadConfig` refuses to start without Intelligence and a
149155
# licence configured, but it only checks that they are present and well-formed; nothing is
150156
# contacted at start-up and /api/capabilities reads config alone. So this proves the image
@@ -155,7 +161,7 @@ jobs:
155161
-e EMBEDDED_POSTGRES=on \
156162
-e KEY_ENCRYPTION_KEY="$(openssl rand -base64 32)" \
157163
-e TRUSTED_ORIGINS=http://localhost:3001 \
158-
-e OPENBOT_DEV_NO_AUTH=1 \
164+
-e OPENBOT_SINGLE_USER=true \
159165
-e MANAGED_AGENT_AG_UI_URL=http://127.0.0.1:4201/ag-ui \
160166
-e MANAGED_AGENT_TOKEN=ci-not-a-real-token \
161167
-e INTELLIGENCE_API_URL=https://api.intelligence.copilotkit.ai \

CHANGELOG.md

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,15 @@ Newest first. `Unreleased` is what is on `main` and not yet tagged.
88

99
## Unreleased
1010

11+
### Upgrading
12+
13+
Two configurations now refuse to start:
14+
15+
- A provider configured with no `INITIAL_ADMIN_EMAILS`. Set it to at least one address.
16+
- No provider at all with `NODE_ENV=production`. Configure one, or set `OPENBOT_SINGLE_USER=true`.
17+
18+
Sessions survive and nobody signs in again.
19+
1120
### Added
1221

1322
- **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.
2231
supervised service is respawning. A single `verify` check covers every job, so branch protection
2332
needs one entry. The same checks run again against the release commit when a release is published,
2433
so they gate the release rather than the proposal for one.
34+
- **Sign in with Google, Microsoft or Okta.** Any one of them turns sign-in on; configure several
35+
and the sign-in screen offers each, on matching buttons carrying each provider's own mark.
36+
`INITIAL_ADMIN_EMAILS` says who is an administrator. It is required whenever a provider is
37+
configured, because nothing else grants the role, and it is now a floor rather than a one-off:
38+
an address it names is made an administrator at every sign-in, so adding somebody to the list
39+
works even after they have already signed in.
40+
- **SAML and OpenID Connect, registered while running.** `/admin/identity-providers` takes the
41+
metadata a company's identity team supplies and registers their own IdP. Somebody then types their
42+
email address on the sign-in screen and the domain decides which provider they are sent to, so a
43+
company mid-merger can run two. Registering, changing or removing one is administrator-only, which
44+
the upstream plugin does not require: it guards those routes with a session, and anybody who could
45+
reach them could register a provider for a domain and mint themselves colleagues.
46+
- **A People screen.** `/admin/people` lists everybody who has signed in, with the provider they came
47+
through and when they were last here, and lets an administrator promote, demote, or remove
48+
somebody. Removing ends the session they are using and stops the next sign-in, keyed on the
49+
address so signing in again through the provider does not quietly create a new account. Every
50+
change is on the audit trail. Somebody named in `INITIAL_ADMIN_EMAILS` cannot be demoted or
51+
removed here, and nobody can do either to themselves.
2552
- **One container that runs the whole thing.** The root `Dockerfile` builds an image carrying the
2653
app, the API, a Bot computer, and optionally PostgreSQL, supervised together. Point `DATABASE_URL`
2754
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.
92119

93120
### Changed
94121

122+
- **A deployment with no identity provider is one administrator, without a flag.** That is how a
123+
fresh clone reaches the product. Where `NODE_ENV=production`, an unconfigured deployment now
124+
refuses to start instead, because a public URL where every visitor is an administrator is silent
125+
and looks like it works. `OPENBOT_SINGLE_USER=true` replaces `OPENBOT_DEV_NO_AUTH`, which is still
126+
honoured, and is how somebody says they meant an open deployment.
127+
- **Requires Better Auth 1.7**, which adds an `issuer` to every account. Migrations `0002` to `0004`
128+
add the column, backfill existing rows with their provider's real issuer, and then make it
129+
required, so nobody is asked to sign in again.
95130
- **Where a Bot's computer runs is now a plug.** One `ComputerProvider` interface sits under the
96131
gateway, with the Docker supervisor as one implementation and a shared computer as another. A
97132
computer somewhere else is an adapter rather than a change to the governed path. Thanks to

README.md

Lines changed: 50 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ your own machine.
2727

2828
> **Alpha, and under active development.** OpenBot is early. Expect rough edges and bugs, and expect things to move. Issues and pull requests are welcome.
2929
30-
> **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.
30+
> **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.
3131
3232
## What it is
3333

@@ -149,6 +149,8 @@ as one replica for now.
149149
- **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.
150150
- **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.
151151
- **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.
152+
- **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.
153+
- **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.
152154
- **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.
153155
- **Credentials encrypted at rest**: stored through `/admin/credentials`, never returned by an API, and redacted from audit events.
154156
- **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:
191193

192194
| Variable | Use |
193195
| ------------------------------------ | ------------------------------------------------------------------------- |
194-
| `OPENBOT_DEV_NO_AUTH` | Admits every request as one administrator. How OpenBot runs today. |
196+
| `OPENBOT_SINGLE_USER` | Admits every request as one administrator where an unconfigured deployment would otherwise refuse to start. |
195197
| `OPENAI_BASE_URL` | Answers the OpenAI-shaped calls from somewhere else: a gateway, a proxy. |
196198
| `ANTHROPIC_BASE_URL`, `GOOGLE_GENERATIVE_AI_BASE_URL` | The same, for those two APIs. |
197199
| `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.
227229

228230
More detail: [docs/architecture.md](docs/architecture.md).
229231

230-
## Sign in with Google
232+
## Sign in
231233

232-
`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:
234+
Nothing configured means one administrator and no sign-in, which is how a fresh clone reaches the
235+
product. Configure **any one** of Google, Microsoft or Okta to turn sign-in on. Configure more than
236+
one and the sign-in screen offers each of them.
237+
238+
These four are needed whichever you pick:
233239

234240
```sh
235-
BETTER_AUTH_URL=http://localhost:3001
236-
BETTER_AUTH_SECRET= # openssl rand -base64 32, at least 32 characters
237-
GOOGLE_OAUTH_CLIENT_ID=
238-
GOOGLE_OAUTH_CLIENT_SECRET=
241+
BETTER_AUTH_URL=http://localhost:3001 # where OAuth callbacks come back to
242+
BETTER_AUTH_SECRET= # openssl rand -base64 32
243+
TRUSTED_ORIGINS=http://localhost:3010 # where the app is served from
244+
INITIAL_ADMIN_EMAILS=you@example.com # comma separated
239245
```
240246

241-
Then set the two that decide who gets in and from where:
247+
Then the provider. Register the redirect URI shown beside it.
248+
249+
```sh
250+
# Google — http://localhost:3001/api/auth/callback/google
251+
GOOGLE_OAUTH_CLIENT_ID=
252+
GOOGLE_OAUTH_CLIENT_SECRET=
242253

243-
- `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.
244-
- `INITIAL_ADMIN_EMAILS` — comma separated. An address listed here becomes an administrator the first time it signs in; everybody else becomes a user.
254+
# Microsoft — http://localhost:3001/api/auth/callback/microsoft
255+
MICROSOFT_OAUTH_CLIENT_ID=
256+
MICROSOFT_OAUTH_CLIENT_SECRET=
257+
MICROSOFT_OAUTH_TENANT_ID=common # your directory GUID for staff only
245258

246-
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.
259+
# Okta — http://localhost:3001/api/auth/callback/okta
260+
OKTA_OAUTH_CLIENT_ID=
261+
OKTA_OAUTH_CLIENT_SECRET=
262+
OKTA_OAUTH_ISSUER=https://example.okta.com/oauth2/default
263+
```
247264

248-
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.
265+
Restart. Accounts, sessions and roles are stored in the same PostgreSQL database as everything else.
266+
267+
- `INITIAL_ADMIN_EMAILS` is required, because nothing else grants the administrator role and no
268+
screen can promote somebody afterwards. It is re-read on every sign-in, so editing it takes effect
269+
the next time that person signs in.
270+
- `MICROSOFT_OAUTH_TENANT_ID` defaults to `common`, which admits personal Microsoft accounts as well
271+
as work ones. On a multi-tenant app registration Entra may send no `email` claim at all, so
272+
OpenBot falls back to `upn` and then `preferred_username`. If none of the three arrives the
273+
sign-in is refused and the reason is logged: add `email` as an optional claim, or use your
274+
directory GUID here.
275+
- A half-configured provider is refused at start-up rather than at somebody's first attempt to sign
276+
in: a client id with no secret, a secret shorter than 32 characters, or an Okta issuer with no
277+
credentials behind it.
278+
- **SAML and OIDC** are registered while the deployment runs rather than configured here. Sign in as
279+
an administrator and go to Admin → Identity providers with the metadata your identity team gave
280+
you. People then sign in by typing their email address, and the domain decides which provider
281+
they are sent to.
282+
- **Put TLS in front of any deployment.** A page served over plain `http://` on anything but
283+
localhost is not a secure context, and sign-in cookies want `Secure`.
249284

250285
## Keeping it to your machine
251286

@@ -280,6 +315,8 @@ Use `bash scripts/start.sh` for the whole stack. Use `bun run dev` only when you
280315
- [docs/configuration.md](docs/configuration.md)
281316
- [docs/development.md](docs/development.md)
282317
- [docs/coworkers.md](docs/coworkers.md)
318+
- [docs/deployment.md](docs/deployment.md)
319+
- [docs/releasing.md](docs/releasing.md)
283320

284321
## Contributing
285322

app/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
"dependencies": {
1616
"@ag-ui/core": "0.0.57",
1717
"@base-ui/react": "^1.6.0",
18+
"@better-auth/sso": "^1.7.1",
1819
"@copilotkit/react-core": "1.68.3",
1920
"@fontsource-variable/inter": "^5.3.0",
2021
"@shadcn/react": "^0.3.0",

app/src/components/admin/admin-sidebar.tsx

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import {
22
IconArrowLeft,
3+
IconBuildingBank,
34
IconCode,
45
IconDeviceDesktop,
56
IconKey,
@@ -8,6 +9,7 @@ import {
89
IconPlugConnected,
910
IconPuzzle,
1011
IconShieldCheck,
12+
IconUsers,
1113
} from "@tabler/icons-react";
1214
import { Link, type LinkOptions } from "@tanstack/react-router";
1315
import type * as React from "react";
@@ -86,6 +88,21 @@ const GROUPS: {
8688
},
8789
],
8890
},
91+
{
92+
label: "Who can get in",
93+
items: [
94+
{
95+
title: "People",
96+
icon: IconUsers,
97+
linkOptions: { to: "/admin/people" },
98+
},
99+
{
100+
title: "Identity providers",
101+
icon: IconBuildingBank,
102+
linkOptions: { to: "/admin/identity-providers" },
103+
},
104+
],
105+
},
89106
{
90107
label: "What happened",
91108
items: [

0 commit comments

Comments
 (0)