GitHub
08/20/2026, 8:09 PMsaml and oidc sections in serviceconfig.SectionRegistry
were explicitly marked non-editable. Changing an IdP issuer URL, client
secret, or metadata URL required editing the config file and restarting
osctrl-api. This was the same problem log sinks had before the
pkg/logsinks refactor — the last major subsystem without a DB/frontend
story.
Solution
Replace the YAML-only auth provider configuration with a DB-backed,
frontend-editable system that supports multiple OIDC and SAML providers,
auto-generated SAML signing keys (no files on disk), a dynamic typed
form (no raw JSON), IdP metadata fetch, connection testing, and
first-run admin bootstrap via JIT provisioning.
Architecture
New package: pkg/authproviders
• AuthProvider model (auth_providers table): one row per IdP
configuration. Fields: Name, Type (oidc/saml), Enabled, Config
(JSON), Source (service/db), Info. Global — not tied to
environments. Multiple rows of the same type are allowed and expected.
• Registry: maps oidc and saml to ProviderSpec with typed
field schema, decode, and build functions. OIDC has 10 fields
(IssuerURL, ClientID, ClientSecret (secret), RedirectURL,
Scopes, UsernameClaim (select), GroupsClaim, RequiredGroups,
JITProvision, UsePKCE). SAML has 13 fields including
IDPMetadataURL, IDPMetadataXML (multiline text), SigningCertPEM
(multiline text), SigningKeyPEM (secret), ForceAuthn,
RequireAssertionSigned, ReplayWindow.
• CRUD: Create, Update (with secret merge), Get, Delete,
List, ListEnabled, RevertToService.
• Seed(params): translates flagParams.OIDC and flagParams.SAML
into rows (create-if-missing, only when Enabled=true). Stale seed
rows synced; operator-edited rows never overwritten. Idempotent.
• BuildProviders(ctx): reads enabled rows, decodes configs,
expands {id} placeholders in URLs, calls oidc.NewOIDCProvider or
saml.NewSAMLProvider. Fail-fast on IdP unreachable.
• Redaction/Merge: ClientSecret (OIDC) and SigningKeyPEM (SAML)
redacted to "***", merged on edit.
• Tests: 9 tests covering CRUD, revert, redaction, merge, seed
(enabled/disabled/idempotent).
SAML provider: auto-generated signing keys
pkg/auth/saml/config.go gained SigningCertPEM and SigningKeyPEM
fields (inline PEM stored in DB config JSON — no files on disk needed).
pkg/auth/saml/provider.go now:
• Accepts inline PEM bytes via parseSPKeyPair (no os.ReadFile).
• Auto-generates a self-signed RSA 2048-bit keypair with 10-year
validity via generateSPKeyPair when no signing material is provided.
• Still supports legacy file paths (`SigningCertPath`/`SigningKeyPath`)
for backwards compat.
API handlers
`cmd/api/handlers/auth_providers.go`: full CRUD + types + test + apply +
revert + fetch-metadata. All admin-only, audit-logged, gated by
serviceConfigEnabled.
| Method | Route | Purpose |
| ------ | ----------------------------------------- | ----------------------------------------- |
| GET | /api/v1/auth-providers | List (secrets redacted) |
| GET | /api/v1/auth-providers/types | Registry: type, description, field schema |
| GET | /api/v1/auth-providers/{id}?reveal={0\|1} | One provider |
| POST | /api/v1/auth-providers | Create |
| PUT | /api/v1/auth-providers/{id} | Update (secret merge) |
| DELETE | /api/v1/auth-providers/{id} | Delete |
| POST | /api/v1/auth-providers/{id}/revert | Revert to service config |
| POST | /api/v1/auth-providers/test | Test connection (body = config) |
| POST | /api/v1/auth-providers/fetch-metadata | Fetch IdP metadata XML from URL |
| POST | /api/v1/auth-providers/apply | Queue reload-auth-providers |
`cmd/api/handlers/auth_provider_registry.go`: AuthProviderRegistry
type — holds live providers, Get(id), AllByType(typ),
AllProviders(), Replace(entries) for hot-reload.
`cmd/api/handlers/auth_methods.go`: now returns a providers[] array
with {type, name, id, loginUrl} per enabled provider. Falls back to
legacy `OIDCEnabled`/`SAMLEnabled` booleans when the registry is nil.
Service commands
New ActionReloadAuthProviders = "reload-auth-providers" — allowlisted
and validated.
Service config
Dropped saml and oidc from SectionRegistry (both API entries). Now
owned by pkg/authproviders. Tests updated.
JIT provisioning: first-run admin bootstrap
`cmd/api/handlers/auth_resolve.go:resolveFederatedUser`: when JIT
provision is enabled and the user doesn't exist, the function calls
h.Users.CountAdmins() before creating the new `AdminUser`:
• Zero admin users exist (first-run bootstrap): the new user is
created with admin=true, so the operator can immediately manage the
system after their first federated login.
• One or more admin users already exist: the new user is created
with admin=false — an existing admin must promote them manually.
This prevents a federated user from self-escalating to admin on a
system that already has an operator.
The admin=true path is only reachable when CountAdmins() == 0, so
on any already-administered system JIT users are always non-admin. Five
tests cover both paths plus existing cases (JIT disabled, existing user
by name, local account claim rejection).
Frontend
• frontend/src/api/auth-providers.ts: typed API client.
• frontend/src/features/auth-providers/AuthProvidersPage.tsx:
full admin page — sticky header, table with per-type icons (key for
OIDC, shield for SAML), two-step create flow (type picker → config
form), dynamic typed form driven by the /auth-providers/types schema
(no raw JSON — each field renders the appropriate input: text,
password, checkbox, dropdown, multiline textarea), "Test connection"
button, "Fetch metadata" button (fetches IdP XML from the URL field
server-side, populates the XML textarea — avoids CORS issues), edit,
delete, revert (for source=db), apply with confirm modal. Scrollable
modal body for long forms.
• frontend/src/routes/_app/auth-providers.tsx: route.
• frontend/src/components/chrome/SideNav.tsx: nav entry with shield
icon, gated on features.auth_providers.
• frontend/src/api/features.ts: auth_providers field added.
Main.go wiring
`cmd/api/main.go`: seeds auth providers from flagParams, builds the
live AuthProviderRegistry, wires WithAuthProviders(registry, mgr),
registers all routes (list, types, get, create, update, delete, revert,
test, fetch-metadata, apply).
Dynamic typed form
The auth provider editor form is fully dynamic — no JSON textarea. Each
field from the /auth-providers/types schema renders the appropriate
input control based on its `type`:
• string → text input
• password → password input (for ClientSecret, SigningKeyPEM)
• boolean → checkbox (for JITProvision, UsePKCE, ForceAuthn,
RequireAssertionSigned)
• select → dropdown (for `UsernameClaim`:
preferred_username/email/sub)
• integer → number input (for ReplayWindow)
• text → multiline textarea (for IDPMetadataXML,
SigningCertPEM)
The buildConfig helper converts the flat field-values map back into
the JSON object the API expects, with special handling for Scopes and
RequiredGroups (comma-separated string → []string). Secret fields are
pre-filled from a reve…
jmpsec/osctrlGitHub
08/20/2026, 10:11 PM