Skip to content

feat: local auth bootstrapping - #7671

Open
njhale wants to merge 4 commits into
obot-platform:mainfrom
njhale:local-auth-bootstrap
Open

feat: local auth bootstrapping#7671
njhale wants to merge 4 commits into
obot-platform:mainfrom
njhale:local-auth-bootstrap

Conversation

@njhale

@njhale njhale commented Aug 27, 2026

Copy link
Copy Markdown
Member

Provision the first local auth owner from deployment settings and let that owner
switch to a replacement auth provider without a bootstrap token.

An operator sets an owner email and a high-entropy setup token. Obot configures
local auth, creates the owner with an unusable password, and stores only a hash
of the setup token. The owner activates through a fragment-delivered link, and
their session can do nothing but read its own profile, set a password, or sign
out until setup is complete. A completed account is never rearmed or reset from
environment settings.

Switching away from local auth stages the replacement's settings while local
auth keeps serving logins, verifies them through a one-time login bound to the
verification that issued it, and then activates as a single provider
configuration change so a partial failure cannot lock everyone out.

@njhale

njhale commented Aug 27, 2026

Copy link
Copy Markdown
Member Author

@claude review

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This automated review found real issues on this local-auth bootstrapping PR — see the inline comments (an open-redirect on the change-password route, an ownership-check ordering issue in EnsureInitialOwner, and missing colocated specs for the new routes/component) — so a human review is still needed. Beyond those, I also checked the bootstrap-token disabling logic in pkg/services/config.go (OBOT_BOOTSTRAP_TOKEN is ignored whenever the initial-owner env vars are set) and confirmed it's an intentional mutual-exclusivity design between local-auth bootstrapping and legacy bootstrap-token auth, not a bug.

Extended reasoning...

Beyond the three confirmed inline findings, I examined pkg/services/config.go around line 1069-1117 where bootstrapOptions = append(bootstrapOptions, bootstrap.Disabled()) unconditionally disables OBOT_BOOTSTRAP_TOKEN auth whenever the initial local-auth owner env vars (LocalAuthInitialOwnerEmail/LocalAuthInitialOwnerSetupToken) are configured, regardless of whether the owner has already been provisioned. This looked at first glance like it could permanently lock out bootstrap-token access, but reading the surrounding code confirms it is a deliberate design choice: local-auth owner provisioning and the legacy bootstrap-token mechanism are treated as mutually exclusive auth-bootstrapping paths, with a slog.Warn emitted to inform the operator when the token is being ignored. This is consistent with the ADR's described intent and is not a defect. The other candidate I looked at (missing spec for routes/activate/+page.svelte:61) duplicates one of the three findings already queued as inline comments, so it isn't new information. Given the security-sensitive nature of this change (auth bootstrapping, password-change flows, redirect handling) and the confirmed open-redirect and ordering bugs already flagged inline, this PR clearly warrants human review regardless of this additional check.

Comment thread ui/user/src/routes/change-password/+page.svelte
Comment thread pkg/localauth/users.go Outdated
Comment thread ui/user/src/routes/activate/+page.svelte

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds secure Local-auth bootstrapping for provisioned Obot environments using expiring setup links and restricted password-setup sessions.

Changes:

  • Provisions an initial owner and disables bootstrap authentication.
  • Adds activation, forced password-change flows, and backend restrictions.
  • Updates admin UI, tests, Helm configuration, documentation, and ADR.

Reviewed changes

Copilot reviewed 44 out of 45 changed files in this pull request and generated 5 comments.

Show a summary per file
File Description
ui/user/src/routes/change-password/page.svelte.spec.ts Tests password-change UI.
ui/user/src/routes/change-password/+page.ts Guards password-change route.
ui/user/src/routes/change-password/+page.svelte Implements password setup form.
ui/user/src/routes/activate/page.svelte.spec.ts Tests account activation UI.
ui/user/src/routes/activate/+page.svelte Exchanges setup-link tokens.
ui/user/src/routes/+layout.ts Redirects restricted users.
ui/user/src/lib/services/user/types.ts Exposes password-change state.
ui/user/src/lib/services/user/operations.ts Adds activation/password APIs.
ui/user/src/lib/services/admin/types.ts Extends Local user metadata.
ui/user/src/lib/services/admin/operations.ts Sends password-change options.
ui/user/src/lib/constants.ts Allows anonymous activation route.
ui/user/src/lib/components/admin/LocalAuthConfigure.svelte.spec.ts Tests admin password controls.
ui/user/src/lib/components/admin/LocalAuthConfigure.svelte Adds forced-change management.
pkg/storage/openapi/generated/openapi_generated.go Updates generated user schema.
pkg/services/config.go Configures initial-owner provisioning.
pkg/proxy/proxy.go Propagates restricted-session state.
pkg/localauth/users.go Implements provisioning and password changes.
pkg/localauth/users_test.go Tests initial-owner lifecycle.
pkg/localauth/provider.go Adds activation sessions and redirects.
pkg/localauth/password_test.go Removes superseded redirect tests.
pkg/gateway/types/localauth.go Adds setup state fields.
pkg/gateway/server/user.go Returns password-change requirement.
pkg/gateway/client/localauth.go Persists activation and completion state.
pkg/gateway/client/localauth_test.go Tests setup-session behavior.
pkg/gateway/client/identity_user_limit_test.go Adapts Local user test API.
pkg/bootstrap/bootstrap.go Supports disabling bootstrap login.
pkg/bootstrap/bootstrap_test.go Tests disabled bootstrap state.
pkg/auth/redirect.go Centralizes safe redirects.
pkg/auth/redirect_test.go Tests redirect sanitization.
pkg/auth/auth.go Extends serialized auth state.
pkg/api/server/server.go Enforces restricted-session access.
pkg/api/server/password_change_test.go Tests allowed restricted requests.
pkg/api/router/router.go Registers activation endpoints.
pkg/api/handlers/localauth.go Handles activation and password completion.
pkg/api/authz/authz.go Authorizes setup-flow requests.
docs/docs/installation/enabling-authentication.md Documents initial-owner setup.
docs/docs/installation/docker-deployment.md Adds Docker setup guidance.
docs/docs/configuration/server-configuration.md Documents new environment variables.
docs/docs/configuration/auth-providers.md Documents forced password changes.
chart/values.yaml Adds Helm setup values.
chart/templates/secret.yaml Validates and stores setup secrets.
chart/templates/NOTES.txt Prints activation guidance.
chart/templates/internal-configmap.yaml Prevents secret ConfigMap exposure.
apiclient/types/user.go Extends API user representation.
adr/2026-08-14-initial-local-owner-setup.md Records the architectural decision.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread pkg/gateway/client/localauth.go
Comment thread pkg/api/handlers/localauth.go
Comment thread pkg/services/config.go
Comment thread ui/user/src/routes/activate/+page.svelte Outdated
Comment thread docs/docs/installation/enabling-authentication.md Outdated
@njhale
njhale force-pushed the local-auth-bootstrap branch from 6126c1e to 835ba87 Compare August 29, 2026 01:54
@njhale
njhale marked this pull request as ready for review August 31, 2026 13:44
Copilot AI review requested due to automatic review settings August 31, 2026 13:44

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Claude Code Review

This pull request is from a fork — automated review is disabled. A repository maintainer can comment @claude review to run a one-time review.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@njhale
njhale requested a balanced review from Copilot August 31, 2026 13:45
@njhale

njhale commented Aug 31, 2026

Copy link
Copy Markdown
Member Author

@claude review

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Findings marked 🟡 are optional suggestions and need no follow-up push.

Additional findings (outside the current diff — GitHub can't attach inline comments there):

  • 🔴 pkg/gateway/server/oauth.go — The new auth-provider Verify flow is broken end-to-end: redirect() unconditionally calls CreateAPIKeyFromSetupTokenRequest, which still rejects any TokenRequest.Purpose other than TokenRequestPurposeSetup (pkg/gateway/client/apikey.go:240), but Verify() creates its token request with the new TokenRequestPurposeAuthProviderVerify. Every admin who stages a replacement provider and completes the verification login gets an error instead of being promoted back to /admin/auth-providers, so staged providers can never be verified/activated via this feature. Fix: update CreateAPIKeyFromSetupTokenRequest (or add a verify-specific completion path) to accept TokenRequestPurposeAuthProviderVerify, matching the purposes already accepted by CreateTokenRequestState/VerifyTokenRequestState.

    Extended reasoning...

    Admin stages a replacement provider (Stage), calls Verify() which creates a TokenRequest{Purpose: TokenRequestPurposeAuthProviderVerify} and returns a redirectURL to /api/oauth/start/{id}/... . oauth.go's oauth() (line ~39) now accepts this purpose via the widened CreateTokenRequestState (token.go oauthRoundTripPurposes) and starts the OAuth dance. After a successful login with the staged provider, redirect() (oauth.go) calls VerifyTokenRequestState (also widened, succeeds) then unconditionally calls apiContext.GatewayClient.CreateAPIKeyFromSetupTokenRequest(ctx, userID, tr) at line 88. That function (pkg/gateway/client/apikey.go:240) still does if tr.Purpose != types.TokenRequestPurposeSetup { return error }, so it fails for the verify-purpose token, s.errorToken() records the error and returns HTTP 500 instead of redirecting to tr.CompletionRedirectURL (/admin/auth-providers?verified=...). Activate() can then never be reached because the caller never actually completes a successful sign-in through the staged provider via this path.

    Verification: normal severity — a new feature this PR adds is broken end-to-end. Verify() (pkg/api/handlers/authprovider.go:306-316) creates a TokenRequest with Purpose=TokenRequestPurposeAuthProviderVerify and redirects to /oauth/start/{id}/{ns}/{name}. The oauth start/redirect handlers (pkg/gateway/server/router.go:65-66 -> oauth.go) now accept this purpose because CreateTokenRequestState (token.go:181) and…

Comment thread pkg/api/handlers/authprovider.go
Comment thread pkg/proxy/proxy.go
Comment thread pkg/gateway/server/dispatcher/dispatcher.go Outdated
Comment thread pkg/api/handlers/authprovider.go
Copilot AI review requested due to automatic review settings August 31, 2026 18:07
@njhale

njhale commented Aug 31, 2026

Copy link
Copy Markdown
Member Author

@claude review

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code review completed

Nothing new to post: everything this review found is already covered by existing comments on this pull request or didn't merit a separate one.

Copilot AI review requested due to automatic review settings August 31, 2026 18:37

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@njhale
njhale force-pushed the local-auth-bootstrap branch from 538a7c5 to 424e65b Compare August 31, 2026 21:58
Copilot AI review requested due to automatic review settings August 31, 2026 21:58

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@njhale
njhale force-pushed the local-auth-bootstrap branch from 424e65b to eefd706 Compare September 1, 2026 12:00
Copilot AI review requested due to automatic review settings September 1, 2026 12:00
Copilot AI review requested due to automatic review settings September 2, 2026 15:20
@njhale
njhale force-pushed the local-auth-bootstrap branch from 7405ae6 to cd4cba1 Compare September 2, 2026 15:20

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Verification state is not bound to staged configuration, and partial provider switches are not retry-safe.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Review details

Suppressed comments (2)

pkg/controller/handlers/providerconfigurationchange/providerconfigurationchange.go:258

  • The switch is not retry-safe after this deletion. If any later step fails (deconfiguring the outgoing provider, updating either status, or advancing a daemon revision), reconciliation retries with the incoming credential already promoted but no replacement credential left. Depending on whether the outgoing credential was removed, GetConfiguredAuthProvider then either still returns the outgoing provider and the retry terminates with “no staged configuration”, or returns the incoming provider and fails the expected-outgoing conflict check. This can strand two live credentials or stale provider status instead of completing recovery. Keep enough durable phase/state to recognize and finish a partially applied switch (or make the credential transition transactional) before deleting the replacement record.
		// Cleared here rather than in the caller, so there is no window where a provider is both
		// configured and still staged against itself.
		if _, err := h.gatewayClient.DeleteCredential(ctx, system.ReplacementAuthProviderCredentialContext, authProvider.Name); err != nil {
			return fmt.Errorf("clear staged configuration for auth provider %q: %w", authProvider.Name, err)
		}
		if err := h.deconfigureAuthProvider(ctx, client, outgoingProvider); err != nil {

pkg/api/handlers/authprovider.go:275

  • Clearing only the completed verification cache after staging does not reliably invalidate proof for the previous settings. The new settings are already committed if this delete fails, and an earlier in-flight verification callback can complete after the delete and repopulate the cache; Activate checks only the provider name, so either case lets proof obtained against old settings activate the new credential. Bind cached proof to a staged credential generation and compare it during activation, or atomically revoke/consume all earlier verification requests as part of re-staging.
	// These settings are not the ones any earlier verification ran against, so its result no longer
	// describes what activation would promote.
	if err := req.GatewayClient.ClearTempUserCache(req.Context()); err != nil {
		return fmt.Errorf("failed to clear the verification for the previous settings: %w", err)
  • Files reviewed: 68/69 changed files
  • Comments generated: 1
  • Review effort level: Balanced

Comment thread pkg/gateway/types/tokens.go

@g-linville g-linville left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

looks mostly good

Comment thread chart/templates/NOTES.txt
Comment on lines +76 to +77
For provisioned trial environments, you can replace the bootstrap token with a secure initial-owner setup link. See [Provision an initial local owner with a secure setup link](./enabling-authentication.md#provision-an-initial-local-owner-with-a-secure-setup-link).

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this (and the other docs changes) be documented? This seems like more of a feature for us than for self-hosted users.

Comment thread pkg/api/handlers/authprovider.go Outdated
Comment on lines +278 to +280
// Drop any daemon left from an earlier staging on this replica so the next verification uses
// these settings. Other replicas drop theirs from the revision the change advanced.
ap.dispatcher.StopAuthProvider(authProvider.Namespace, authProvider.Name)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: while it doesn't do any harm to stop the provider daemon here, the replica that handled this request should also stop its daemon based on the ProviderSync that will get created by the controller.

Comment on lines +56 to +57
// The ODP requires a verification to be tied to the Owner who started the switch, so holding the
// ID is not on its own enough to open the replacement provider's login.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: remove direct reference to ODP

Copilot AI review requested due to automatic review settings September 2, 2026 19:46

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Critical and moderate provider-verification and switch-safety issues remain unresolved.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Review details

Suppressed comments (5)

pkg/api/handlers/authprovider.go:275

  • The verification is invalidated only after the replacement settings are committed, and it is keyed only by provider name. A crash/clear failure leaves the previous credential's proof cached; an in-flight callback can also repopulate that cache after this clear. In either case, activate can promote settings that were never verified. Bind the cached verification/token request to a staged-credential revision and require that revision during activation (with invalidation committed alongside re-staging).
	// These settings are not the ones any earlier verification ran against, so its result no longer
	// describes what activation would promote.
	if err := req.GatewayClient.ClearTempUserCache(req.Context()); err != nil {
		return fmt.Errorf("failed to clear the verification for the previous settings: %w", err)

pkg/controller/handlers/providerconfigurationchange/providerconfigurationchange.go:258

  • The switch is not retry-safe after the replacement is promoted. If clearing the staged credential succeeds and any later deconfiguration, status update, or daemon-revision operation fails, the next reconcile cannot reload the staged source (or sees the incoming provider as configured) and records a terminal error, leaving a partially applied switch. Keep enough durable progress state to resume/rollback each step, or perform the credential transition transactionally before deleting the staged source.
		// Cleared here rather than in the caller, so there is no window where a provider is both
		// configured and still staged against itself.
		if _, err := h.gatewayClient.DeleteCredential(ctx, system.ReplacementAuthProviderCredentialContext, authProvider.Name); err != nil {
			return fmt.Errorf("clear staged configuration for auth provider %q: %w", authProvider.Name, err)
		}
		if err := h.deconfigureAuthProvider(ctx, client, outgoingProvider); err != nil {

ui/user/src/routes/activate/+page.svelte:59

  • Activation failures replace the loading state asynchronously, but this notification has no live-region semantics, so assistive technology may not announce that the link was rejected. Mark it as an alert.
    ui/user/src/routes/admin/auth-providers/+page.svelte:529
  • Provider-switch errors appear only after an asynchronous action and are not announced to screen readers. Add alert semantics to this notification so the failure is surfaced without requiring the user to discover it visually.
    ui/user/src/routes/change-password/+page.svelte:75
  • This validation/server error is inserted dynamically but is not exposed as a live alert, so screen-reader users may receive no feedback after submitting the form. Mark the notification as role="alert".
  • Files reviewed: 68/69 changed files
  • Comments generated: 3
  • Review effort level: Balanced

Comment thread pkg/gateway/client/token.go
Comment thread pkg/proxy/proxy.go
Comment thread pkg/gateway/server/dispatcher/dispatcher.go
@njhale
njhale force-pushed the local-auth-bootstrap branch from 998cd89 to d357dbe Compare September 2, 2026 20:12
Copilot AI review requested due to automatic review settings September 2, 2026 20:12

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Unresolved correctness, security, recovery, UI, accessibility, and documentation issues must be addressed.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Review details

Suppressed comments (7)

docs/docs/installation/enabling-authentication.md:57

  • The newly documented mode disables bootstrap login, but Step 2 still tells every operator to use the bootstrap token and never explains how to construct the activation URL. Anyone following this guide with these settings has no usable login instruction. Add the fragment-based activation link and branch Step 2 between initial-owner and bootstrap setup.
| `OBOT_SERVER_LOCAL_AUTH_INITIAL_OWNER_EMAIL` | No | Initial local-auth owner's email. Must be set with the setup token. |
| `OBOT_SERVER_LOCAL_AUTH_INITIAL_OWNER_SETUP_TOKEN` | No | At least 32 characters of high-entropy, randomly generated secret material used to activate the initial owner. Store as a secret; `openssl rand -hex 32` is the recommended generator. |
| `OBOT_SERVER_LOCAL_AUTH_INITIAL_OWNER_SETUP_TOKEN_EXPIRATION_HOURS` | No | Setup-link validity in hours. Defaults to `168`. |

pkg/api/handlers/authprovider.go:275

  • Invalidating the old verification only after the staging change completes leaves a race: once the controller publishes the new replacement credential, a concurrent Activate request can still consume the old temp-user cache and switch to settings that were never verified. Bind verification to a credential revision and validate that revision inside the serialized switch, or invalidate it atomically before the new credential becomes visible.
	// These settings are not the ones any earlier verification ran against, so its result no longer
	// describes what activation would promote.
	if err := req.GatewayClient.ClearTempUserCache(req.Context()); err != nil {
		return fmt.Errorf("failed to clear the verification for the previous settings: %w", err)

pkg/controller/handlers/providerconfigurationchange/providerconfigurationchange.go:258

  • The replacement's staged credential is deleted before deconfigureAuthProvider and the later status/daemon updates. If any of those operations fails, the retry finds the incoming credential already promoted but no staged credential, records a terminal error, and can leave both providers configured or cleanup half-complete. The switch reconciliation needs an idempotent state machine (or a credential transaction) that can resume every partial state and only retires staging after the outgoing credential is removed.
		// configured and still staged against itself.
		if _, err := h.gatewayClient.DeleteCredential(ctx, system.ReplacementAuthProviderCredentialContext, authProvider.Name); err != nil {
			return fmt.Errorf("clear staged configuration for auth provider %q: %w", authProvider.Name, err)
		}
		if err := h.deconfigureAuthProvider(ctx, client, outgoingProvider); err != nil {

pkg/gateway/client/token.go:146

  • This predicate leaves a verification usable for its full 15-minute expiry even after VerifyTokenRequestState consumes its OAuth state, and it does not bind the request to a provider or staged-settings revision. A browser retaining the cookie can therefore keep reaching the staged login, and an in-flight verification can be reused after another provider/settings set is staged. Persist the target provider and staged revision on the token request, validate them on both OAuth legs, and mark the verification consumed after the callback.
    pkg/localauth/users.go:138
  • This lookup makes “initial owner” provisioning per-email rather than one-time. After owner A completes setup, changing the deployment email/token to B creates another claimable owner; changing it while A is pending also leaves A’s token and setup sessions valid. That allows deployment settings to mint additional owners instead of only provisioning the first one. Persist a singleton provisioning/completion marker and revoke any superseded pending claim before creating a different email.
    ui/user/src/lib/components/admin/McpServerEntryForm.svelte:738
  • If this refresh fails after an entry update, the rejection is discarded and showUpdateExistingDeploymentsConfirm is never set, even when deployments still need updating. The success toast still appears, so users are told the save succeeded without being warned that deployed instances retain the old configuration. Surface the failure or defer the success state until this check completes.
    ui/user/src/lib/components/admin/ProviderConfigure.svelte:494
  • A custom footer bypasses the component's existing read-only guard. On the auth-provider page, read-only users can therefore see and invoke Discard, Continue, Sign in, and Switch actions; the backend rejects them, but the dialog no longer behaves as a read-only view.
  • Files reviewed: 67/68 changed files
  • Comments generated: 3
  • Review effort level: Balanced

Comment thread ui/user/src/lib/components/admin/McpServerEntryForm.svelte
Comment thread ui/user/src/routes/activate/+page.svelte Outdated
Comment thread ui/user/src/routes/change-password/+page.svelte Outdated
Obot serves logins from one configured auth provider, and "configured"
means holding a complete credential in that provider's own context. That
made both getting the first owner and changing providers hazardous: the
former required a bootstrap token and manual provider setup, and the
latter had a window with nothing configured and no session left to fix it.

A provisioned deployment can now name an initial Local owner from an email
and a high-entropy setup token. Obot configures Local, creates the account
with an unusable password, and stores only the token's hash. The activation
link carries the token in its fragment, so it never reaches a request line
or an access log. Until a password is set, the session is restricted to the
password page and the handful of endpoints it needs. Completion is atomic
and single-winner. Bootstrap is disabled while this is configured.

A replacement provider's settings are staged into a separate credential
context, so it is not configured, does not serve logins, and survives a
restart without changing who does. A one-time login through it, authorized
only for the owner who started the switch and pinned to their browser,
proves it works and produces the identity that will hold Owner. Activation
promotes the staged credential before deconfiguring the outgoing provider,
in one reconcile, so a partial failure still leaves someone able to sign
in. Deconfiguring the provider currently serving logins is refused.

Signed-off-by: Nick Hale <4175918+njhale@users.noreply.github.com>
Signed-off-by: Nick Hale <4175918+njhale@users.noreply.github.com>
Copilot AI review requested due to automatic review settings September 2, 2026 22:41
@njhale
njhale force-pushed the local-auth-bootstrap branch from d357dbe to dc723bb Compare September 2, 2026 22:41

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

A critical provider-verification flaw and multiple moderate security and reliability issues remain unresolved.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Review details

Suppressed comments (4)

pkg/api/handlers/authprovider.go:275

  • The new staged settings are committed before the old verification cache is cleared. If this delete fails or the process stops here, the endpoint returns an error but the new settings remain staged; because activation matches proof only by provider name, the previous verification now authorizes activating settings that were never tested. Invalidate/version the proof atomically with replacing the staged credential (and account for in-flight verification callbacks).
	// These settings are not the ones any earlier verification ran against, so its result no longer
	// describes what activation would promote.
	if err := req.GatewayClient.ClearTempUserCache(req.Context()); err != nil {
		return fmt.Errorf("failed to clear the verification for the previous settings: %w", err)

pkg/api/handlers/localauth.go:174

  • This cookie-authenticated password mutation has no CSRF/origin check. Context.Read unmarshals JSON regardless of Content-Type, so a same-site sibling origin can submit a crafted text/plain form with the victim's Lax cookie, choose an attacker-known password, and complete the owner setup. Require a same-origin request or enforce an equivalent CSRF boundary before accepting the body.
	var body localAuthUserRequest
	if err := req.Read(&body); err != nil {
		return types.NewErrBadRequest("invalid request body: %v", err)
	}

pkg/controller/handlers/providerconfigurationchange/providerconfigurationchange.go:258

  • The replacement credential is deleted before the outgoing provider is deconfigured and before status/daemon updates complete. If any later operation fails, reconciliation retries without the staged credential (or sees the incoming provider as configured), converts the retry into a terminal error, and leaves the switch partially applied. Keep the operation's retry inputs until all steps finish and make the switched reconciliation explicitly idempotent for partially promoted/deconfigured states.
		// Cleared here rather than in the caller, so there is no window where a provider is both
		// configured and still staged against itself.
		if _, err := h.gatewayClient.DeleteCredential(ctx, system.ReplacementAuthProviderCredentialContext, authProvider.Name); err != nil {
			return fmt.Errorf("clear staged configuration for auth provider %q: %w", authProvider.Name, err)
		}
		if err := h.deconfigureAuthProvider(ctx, client, outgoingProvider); err != nil {

ui/user/src/routes/admin/auth-providers/+page.svelte:557

  • This warning says everyone is signed out, but the verification session from the replacement provider is deliberately preserved so the user finishes the switch signed in. Describe the outgoing-provider sessions specifically to avoid misleading the owner about the post-switch state.
  • Files reviewed: 67/68 changed files
  • Comments generated: 3
  • Review effort level: Balanced

Comment thread pkg/gateway/server/oauth.go Outdated
Comment thread ui/user/src/lib/components/admin/ProviderConfigure.svelte Outdated
Comment thread docs/docs/installation/enabling-authentication.md
Signed-off-by: Nick Hale <4175918+njhale@users.noreply.github.com>
Signed-off-by: Nick Hale <4175918+njhale@users.noreply.github.com>
Copilot AI review requested due to automatic review settings September 3, 2026 03:52

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Critical and moderate authentication-switching and owner-promotion issues remain unresolved.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Review details

Suppressed comments (3)

Previously missed (1) — in code that hasn't changed since the last review.

pkg/api/handlers/setup/confirm_owner.go:68

  • PromoteToOwner now creates a UserRoleChange itself, but ConfirmOwner still creates another one immediately below. A newly promoted bootstrap owner therefore emits two role-change resources and triggers duplicate propagation. Remove the old creation block from this handler.

pkg/api/handlers/authprovider.go:275

  • Clearing only the cached result after staging does not invalidate an already-issued verification token. An OAuth callback for the previous settings can race this call (or arrive after the same provider is re-staged), repopulate TempSetupUser, and then satisfy Activate for credentials it never verified. Bind the token request/cache entry to the staged credential version and reject stale callbacks, or invalidate outstanding verification requests as part of the serialized staging change.
	// These settings are not the ones any earlier verification ran against, so its result no longer
	// describes what activation would promote.
	if err := req.GatewayClient.ClearTempUserCache(req.Context()); err != nil {
		return fmt.Errorf("failed to clear the verification for the previous settings: %w", err)

ui/user/src/routes/admin/auth-providers/+page.svelte:559

  • This warning is not universally true. Verified providers are linked to an existing Obot user by verified email (pkg/gateway/client/identity.go:239-243), so a Google-to-GitHub switch using the same verified address reuses the user and their work. Make the copy conditional on provider identity semantics or explain that transfer depends on verified-email matching.
  • Files reviewed: 67/68 changed files
  • Comments generated: 2
  • Review effort level: Balanced

@g-linville

Copy link
Copy Markdown
Member

btw I reviewed backend only

</div>
<p class="text-muted-content text-xs font-light">
Not the right account?
<button class="text-link underline" onclick={handleVerifyStagedProvider}>

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

disabled={switching} here too!

// Auth provider mutations disable or replace the way everyone signs in, so they belong to owners
// and administrators only. An auditor is a read-only role and must not reach them, even though it
// is granted the reveal route that sits under the same path prefix.
func TestAuthProviderMutationsAreNotAvailableToAuditors(t *testing.T) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The name of this test function is not indicative of what it is testing.

Comment thread pkg/api/authz/authz.go
Comment on lines +204 to +205
// Reveal is a POST but is a read. The rest of the auth-provider POSTs replace or
// disable how everyone signs in, so an auditor must not inherit them from a prefix.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This comment requires some context. Specifically, I don't know what it is calling out about a prefix.

Comment on lines +213 to +215
if err := ap.license.RequireEntitlements(req.Context(), authProvider.Spec.RequiredEntitlements); err != nil {
return err
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You can use providers.AuthProviderStatus for this and the missing configuration stuff.

Comment on lines +332 to +334
if !req.UserIsOwner() {
return types.NewErrHTTP(http.StatusForbidden, "only an owner can verify a replacement auth provider")
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should be done in the authorization layer and not here.


// Activate promotes the staged provider and deconfigures the outgoing one. It requires a recorded
// verification for the staged provider, which only a successful Verify produces.
func (ap *AuthProviderHandler) Activate(req api.Context) error {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This this also only be available to an owner?

Comment thread pkg/api/server/server.go
// Enforced after audit logging is installed and refreshed provider cookies are replayed, so
// rejected probes stay auditable and a cookie refresh is not lost to a blocked operation.
if utils.FirstSet(user.GetExtra()["password_change_required"]...) == "true" && !passwordChangeRequestAllowed(req) {
if strings.HasPrefix(req.URL.Path, "/api/") {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If the intention here is to send a forbidden error for all API requests and redirect for UI requests, then this can be

Suggested change
if strings.HasPrefix(req.URL.Path, "/api/") {
if req.Pattern != "/" {

Comment thread pkg/api/server/server.go
Comment on lines +271 to +283
func passwordChangeRequestAllowed(req *http.Request) bool {
if isStaticAssetPath(req.URL.Path) || req.URL.Path == "/change-password" || strings.HasPrefix(req.URL.Path, "/change-password/") || req.URL.Path == "/oauth2/sign_out" {
return true
}
return (req.Method == http.MethodGet && slices.Contains([]string{
"/api/me",
"/api/version",
"/api/license",
"/api/app-preferences",
}, req.URL.Path)) ||
(req.Method == http.MethodPost && req.URL.Path == "/api/local-auth/change-password")
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can this be in the authorization layer. We should have access to the user and the extra field there.

The only thing that gives me pause here is the redirect.

Comment thread pkg/auth/redirect_test.go
tests := []struct {
rd, want string
}{
{"", "/"},

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same nit here.

return func(o *options) { o.disabled = true }
}

func New(ctx context.Context, serverURL string, c *client.Client, authProviderGetter configuredAuthProviderGetter, authEnabled, forceEnableBootstrap bool, opts ...Option) (*Bootstrap, error) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It seems weird to have authEnabled and forceEnableBootstrap be sent as bools, but disabled to be set using a functional option pattern.


credEnv := map[string]string{}
cred, err := d.gatewayClient.RevealCredential(ctx, []string{authProvider.Name, system.GenericAuthProviderCredentialContext}, authProvider.Name)
cred, err := d.gatewayClient.RevealCredential(ctx, []string{authProvider.Name, system.GenericAuthProviderCredentialContext, system.ReplacementAuthProviderCredentialContext}, authProvider.Name)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe this is why you did it this way in the other place where I commented you can use providers.AuthProviderStatus. Does it make sense to include this credential context there, too?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants