Choosing which logins an instance offers
Maintainer directive, 2026-09-01, given twice:
"i should now get setup dialog imo where i can choose which logins to enable" "i would like to get a setup dialog where i can choose any login"
A fresh instance sent the operator straight to Microsoft sign-in without ever asking whether that is the login it should offer. This page is the answer: what the setup surface does, where an enabled login is actually written, and — the part that is easy to get wrong and expensive to discover — what it deliberately does not do.
The design of record for the whole instance-setup story is core's
Instance Identity and Setup
(get Doc/Architecture/InstanceIdentityAndSetup), whose slice 3 is the first-run wizard. That
design's five steps — Database · Where · Instance id · Modules · Review — carry no sign-in step at
all; this is that missing step, built first because it is the one an operator hits before anything
else can be reached.
The surface
Settings → Administration → Sign-in providers (/GlobalSettings/SignInProviders), on the
instance you are on. Not the Fleet Console's Set up a new instance dialog, which provisions a
DIFFERENT instance and files a Hosting/InstanceRequest.
It renders, in this order:
- How it is configured now. Every login route this image can offer, whether it is on, which configuration key said so, whether its secret is present (by NAME), and which call registers it. Six rows: Microsoft, Google, LinkedIn, Apple, GitHub, Developer login.
- The chooser. One group per route — a checkbox, a client-id field, and the tenant field for
the one provider that has one. Framework controls bound to layout data; no hand-built markup and
no
/datareplica. - The composed answer. The exact
Authentication__*entries to set, what changes against the instance's current state, and every problem that would follow — a selected provider with no client id, or one whose secret is missing. - The restart notice and the secret guidance, verbatim, below.
Reading the current state comes FIRST, and that is the point
A surface that shows only its own stored answer is precisely the defect
MeshWeaver.Plugins#980 describes:
Provider/Anthropic sat keyless on memex.systemorph.com for a week while Anthropic__ApiKey was
configured in the deployment, and the platform believed it could not serve a model it was serving.
The provenance column — which key decided this row — is what makes that state visible instead of
discoverable a week later.
The table is also honest about the two states that look alike and are not:
- Every route off is called out as "this instance offers no way to sign in". That is a verdict, and it is the only case where it is true — a portal offering only the Developer login or only GitHub is a healthy portal, which is the correction the login page's own notice needed on 2026-08-31.
- A route with a client id and no secret is half-configured: the button renders, the user clicks it, and the handshake fails at the token exchange. Strictly worse than an absent provider, so it is named rather than shown as healthy.
Where an enabled provider is actually persisted
Today: in the host's IConfiguration, and nowhere else. Every login route is registered while
the host is being built, from these keys:
| Route | Key that turns it on | Secret | Registered by |
|---|---|---|---|
| Microsoft | Authentication__Microsoft__ClientId (+ __TenantId) |
Authentication__Microsoft__ClientSecret |
AddOpenIdConnect("Microsoft") |
Authentication__Google__ClientId |
Authentication__Google__ClientSecret |
AddOAuth("Google") |
|
Authentication__LinkedIn__ClientId |
Authentication__LinkedIn__ClientSecret |
AddOAuth("LinkedIn") |
|
| Apple | Authentication__Apple__ClientId |
Authentication__Apple__PrivateKey |
AddApple() |
| GitHub | GitHub__OAuth__ClientId |
GitHub__OAuth__ClientSecret |
MapGitHubLogin() — minimal-API OAuth, not a scheme |
| Developer login | Authentication__EnableDevLogin |
(none) | DevAuthController — self-provisioning |
Each handler is a no-op when its client id is unset, so "which logins does this instance offer" is decided entirely by which of those keys carry a value at boot.
Those keys reach the process from one of two places, and both are the SAME names:
- A fleet-managed instance: the
Hosting/Deploymentrecord'ssignInsection (SignInSpec), rendered byHelmValues.Renderinto the chart's ConfigMap. This is the administered home the live-only config audit enforces — nothing lives only on the cluster. - A self-managed instance (local, Homebrew, a container): the same names as environment
variables or
appsettings.
That is why the surface composes ENTRIES rather than writing a node: one projection serves both homes, and it cannot drift from the key names the host actually reads.
🚨 There is no second home, and none should be added. A Admin/SignIn node holding the same
facts would be exactly the "three places that cannot see each other" problem #980 opens with.
Enabling a login requires a restart. There is no way around it today.
Measured, not assumed, across both repositories:
- Zero occurrences of
IAuthenticationSchemeProvider. Nothing injects it; nothing callsAddSchemeorRemoveSchemeat run time. EveryAddScheme<>call is insideIServiceCollectioncomposition, beforebuilder.Build(). - Zero
IPostConfigureOptions/IConfigureNamedOptionstouching authentication. - The provider lists both halves of the portal read are consumed as
IOptions<T>singletons, notIOptionsMonitor— snapshotted at construction. - In a deployed instance the values arrive as environment variables, which are immutable for the
process lifetime whatever
reloadOnChangesays.
So a client id appearing after boot registers no handler, and /auth/login?provider=X would answer
400 Unknown provider with every value correct — the shape of the production outage
ExternalProvidersReachBothAuthenticationOptionsTest was written for.
The surface therefore says so where the operator decides, rather than letting them discover it:
These answers take effect at the next start. Sign-in providers are registered while the host is being built, so a running process cannot gain or lose one. Apply the entries, then restart the instance — through its deployment record on a managed instance, or by restarting the container on a self-managed one. Never by hand-editing a live pod.
The follow-up that would remove the restart is dynamic scheme registration —
IAuthenticationSchemeProvider.AddScheme plus a named-options factory that runs the full
PostConfigure pipeline. It is genuinely possible and genuinely not free: an OIDC handler's options
are finished by post-configuration, so a scheme added by hand around that pipeline half-works in ways
a unit test does not see. It is not in this slice.
Secrets: collected nowhere, named everywhere
🚨 A client secret is never collected by this surface and never stored on a mesh node. A live
provider key sat in cleartext on a node — readable by anyone with read on the partition — until
2026-08-24 (Providers/Skill/providers.md). A client id is not a secret: it is public in every
redirect URL, which is why the record holds it.
So the surface renders, verbatim:
Secrets are not collected here. A client id is public — it appears in every redirect URL — so this surface records it. A client secret is not, and a mesh node is the wrong place for one: it is readable by everyone with read on the partition. Set each secret in this instance's secret store under the name shown, and this record will name it rather than hold it.
Two tests keep that from decaying into a habit: no state the catalog produces may carry a secret's value under any property, and no composed entry may carry one — or even NAME a secret key.
Who may use it
The platform-admin grant, and nothing else. The AccessAssignment at the Admin scope, which is
what hub.IsGlobalAdmin() reads. Not ownership of the node whose settings page it is, not
Permission.All on some partition, not merely being signed in.
Two doors, one rule (SignInSetupAccess.Decide): the settings MENU asks before contributing the
entry, and the AREA asks again before rendering a byte — a settings area is addressable by URL, so
hiding a menu item hides nothing. Every uncertainty resolves to REFUSED: no viewer, somebody else's
settings page, an unresolved check, a timeout, an exception.
🚨 Note what the grant does not confer: a global admin still has no read, write or list on any
partition's content. Admin/_Access gates platform actions, and choosing an instance's logins is
one; it opens this surface and nothing else.
Awaiting first-run setup
MeshBuilder.IsAwaitingSetup — an instance with no storage and no completed instance.json — is now
read in production for the first time, through InstanceSetupStatus. Its own doc comment has
demanded since MeshWeaver#2550 that "a host that reads this true must serve the SETUP surface and
nothing else", and until now nothing read it at all.
The surface reports the state honestly: an awaiting-setup instance is told that nothing can be
persisted yet, because it has no storage to persist into, and that storage is the first question.
The verdict is read LAZILY — the two hosts call ConfigureMemexMesh (which sets the flag) on
opposite sides of ConfigureMemexPortal, so a value captured at registration time would be correct
on one host and silently false on the other.
What is still to land
This slice is the surface and the composition. Named, so the gap is visible rather than assumed:
| Still to land | Where it belongs |
|---|---|
The instance's own record holds the answer. InstanceManifest (instance.json) gains a sign-in section, so a self-managed instance can persist the choice instead of the operator copying entries. |
Core — MeshWeaver.Mesh.Contract, the design's slice 3 |
The host serves the setup surface and nothing else when IsAwaitingSetup. A terminal branch after UseForwardedHeaders() and the /healthz short-circuit, before UseRouting(). Today an awaiting-setup mesh has no hubs, so a layout area cannot render there at all. |
The portal hosts, once the manifest can hold an answer |
Writing the Hosting/Deployment record's signIn section from the surface on a fleet-managed instance, instead of composing entries to copy. |
This repo, once the record is reachable from the portal's composition |
| Dynamic scheme registration, removing the restart. | MeshWeaver.Blazor.Portal.Authentication |
| The storage, instance-id and module steps of the first-run wizard. | Core, the design's slice 3 |
Where the code is
| Piece | Path |
|---|---|
| The image's capability set — one list, also feeding the two legacy helpers | src/MeshWeaver.Blazor.Portal/Authentication/SignInProviderCatalog.cs |
| The pure composition — entries, changes, problems, the two notices | src/Memex.Portal.Gui/Setup/SignInSetupPlan.cs |
| The access rule, both doors | src/Memex.Portal.Gui/Setup/SignInSetupAccess.cs |
The first production reader of IsAwaitingSetup |
src/Memex.Portal.Gui/Setup/InstanceSetupStatus.cs |
| The settings tab and its rendering | src/Memex.Portal.Gui/Setup/SignInSetupTab.cs |
| The suites | src/Memex.Portal.Gui.Test/SignIn*.cs |