Files
warmbly/internal/api/handler/auth_config.go
T
Matthew Meszaros 734cb5fe08 feat: make self-hosted onboarding survivable by fixing invite_only, which could not onboard anyone (the accept route is JWT-only, so redeeming the invitation that would create your account required already having one, making the self-host default silently identical to fully closed), threading the invitation token through registration so an invited person lands in the inviting organization instead of a stray workspace, gating SSO just-in-time provisioning behind DISABLE_REGISTRATION (it bypassed the gate entirely, so an instance set to true was still open to anyone the IdP would assert) with SSO_AUTO_PROVISION as the opt-out, correcting the OIDC redirect URL that pointed at /api/v1 against a route at /v1 and 404'd every SSO login, scoping the first-launch exemption so it no longer overrides an explicit lockdown, preserving the remaining TTL when restoring a losing setup token so a public endpoint cannot hold the claim window open forever, replacing a generic 403 with typed registration_invite_only, registration_closed, invitation_invalid, setup_token_invalid and setup_already_complete codes that name the next step, logging why no claim link was issued on an already-claimed instance instead of staying silent, adding a warmblyctl operator CLI (status with health checks and a non-zero exit, reissuable setup-link, user create/list/reset-password/grant-admin/revoke-admin/disable-2fa, hash-password) so a locked-out operator no longer needs hand-written psql, adding read-only instance configuration over 104 environment variables with structural secret redaction and fingerprints, 35 health checks, a database-backed settings tier for the three keys no environment variable owns, hiding the signup form when the config already says invite_only rather than failing the whole form with a toast, and documenting first run, accounts and access, configuration, instance health and troubleshooting alongside the root .env.example the README told operators to write but never shipped (#114)
2026-08-16 05:58:11 +02:00

103 lines
4.0 KiB
Go

package handler
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/warmbly/warmbly/internal/config"
)
// DeploymentAuthConfig is what the login screen needs to render truthfully.
//
// Without it the frontends guess: the Turnstile widget mounted even when
// captcha was off server-side, social buttons rendered with no client
// configured, and nothing told a self-hoster their login code was going to a
// log file. Everything here is public, non-secret configuration.
type DeploymentAuthConfig struct {
// Captcha reports whether a Turnstile token is actually verified. When
// false the client must not mount the widget: a self-hosted or air-gapped
// install cannot reach challenges.cloudflare.com.
Captcha bool `json:"captcha"`
// PasswordLogin is false when the deployment authenticates only through
// OIDC or passkeys.
PasswordLogin bool `json:"password_login"`
// LoginCode is always, new_device or off. The client uses it to decide
// whether to expect a code step, and to explain the flow up front.
LoginCode string `json:"login_code"`
// Registration is false, invite_only or true, already resolved through the
// first-launch exemption, so a brand new instance reports open signups.
Registration string `json:"registration"`
// EmailVerification reports whether a signup must confirm an emailed code.
EmailVerification bool `json:"email_verification"`
// MailDelivers is false when the platform mail transport does not put mail
// on the wire (MAIL_TRANSPORT=log). The login screen tells the operator
// where to find codes instead of leaving them waiting for an email.
MailDelivers bool `json:"mail_delivers"`
// Passkeys reports whether WebAuthn can work here at all. It needs a
// secure context, so a plain-http LAN origin disables it rather than
// failing in the browser with an opaque error.
Passkeys bool `json:"passkeys"`
Providers []string `json:"providers"`
// SelfHosted lets the UI drop hosted-only affordances (billing prompts,
// referral fields) that make no sense on someone's own server.
SelfHosted bool `json:"self_hosted"`
// SetupRequired is true while the instance has no accounts at all. The
// login screen redirects to the setup page rather than showing a form
// nobody can yet use.
SetupRequired bool `json:"setup_required"`
// InvitesRequired mirrors registration == invite_only, precomputed so the
// client does not reimplement the meaning of a tri-state string.
InvitesRequired bool `json:"invites_required"`
// DocsURL is where to send someone whose signup was refused by deployment
// policy rather than by anything they did wrong.
DocsURL string `json:"docs_url"`
}
// accountsDocsURL is the page every registration refusal points at.
const accountsDocsURL = "https://docs.warmbly.com/development/accounts-and-access/"
// AuthConfig serves GET /v1/auth/config. Public and unauthenticated by design:
// it is the first request the login screen makes.
func (h *Handler) AuthConfig(c *gin.Context) {
policy := h.AuthService.Policy()
providers := []string{}
if h.ExternalAuthProviders.GoogleIOSClientID != "" || h.GoogleWebSignIn {
providers = append(providers, "google")
}
if h.ExternalAuthProviders.AppleBundleID != "" || h.AppleWebSignIn {
providers = append(providers, "apple")
}
if h.OIDCEnabled {
providers = append(providers, "oidc")
}
registration := h.AuthService.RegistrationMode(c.Request.Context())
c.JSON(http.StatusOK, DeploymentAuthConfig{
Captcha: config.CaptchaProvider() != "none",
PasswordLogin: !policy.DisablePasswordLogin,
LoginCode: policy.LoginCode,
Registration: registration,
EmailVerification: policy.RequireEmailVerification,
MailDelivers: h.MailDelivers,
Passkeys: h.PasskeysUsable,
Providers: providers,
SelfHosted: config.SelfHosted(),
SetupRequired: h.BootstrapService != nil && h.BootstrapService.Required(c.Request.Context()),
InvitesRequired: registration == config.RegistrationInviteOnly,
DocsURL: accountsDocsURL,
})
}