Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions backend/api-files/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -306,6 +306,31 @@ components:
type: string
format: date-time

InstanceConfig:
type: object
description: Public configuration of this instance, used by clients to hide flows the backend would reject
required:
- signup_enabled
- password_login_enabled
- billing_enabled
- auth_providers
properties:
signup_enabled:
type: boolean
description: False when DISABLE_SIGNUP is set and the instance already has users; new accounts then need a team invitation
password_login_enabled:
type: boolean
description: False when DISABLE_PASSWORD_LOGIN is set; only social providers can be used
billing_enabled:
type: boolean
description: True when Stripe billing is configured
auth_providers:
type: array
description: Social login providers that are configured on this instance
items:
type: string
enum: [google, slack, github]

securitySchemes:
BearerAuth:
type: http
Expand All @@ -332,6 +357,18 @@ paths:
"200":
description: Metrics in Prometheus format

/api/config:
get:
summary: Public instance configuration
description: Unauthenticated. Tells clients which sign-up and login flows are enabled on this instance.
responses:
"200":
description: Instance configuration
content:
application/json:
schema:
$ref: "#/components/schemas/InstanceConfig"

/api/auth/social/:provider:
get:
summary: Initiate social login with specified provider
Expand Down
40 changes: 40 additions & 0 deletions backend/internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,12 @@ type Config struct {
GitHubRedirect string
CallbackURL string
SessionSecret string
// DisableSignup rejects new accounts that do not come with a team
// invitation. The very first account of an instance is still allowed.
DisableSignup bool
// DisablePasswordLogin turns off email/password authentication, leaving
// only the configured social providers.
DisablePasswordLogin bool
}
Livekit struct {
APIKey string
Expand Down Expand Up @@ -145,6 +151,12 @@ func Load() (*Config, error) {
c.Auth.GitHubSecret = os.Getenv("GITHUB_SECRET")
c.Auth.GitHubRedirect = fmt.Sprintf("https://%s/api/auth/social/github/callback", c.Server.DeployDomain)

c.Auth.DisableSignup = os.Getenv("DISABLE_SIGNUP") == "true"
c.Auth.DisablePasswordLogin = os.Getenv("DISABLE_PASSWORD_LOGIN") == "true"
if c.Auth.DisablePasswordLogin && !c.hasWebLoginProvider() {
return nil, fmt.Errorf("DISABLE_PASSWORD_LOGIN is set but neither Google nor GitHub login is configured, nobody could sign in")
}

c.Database.DSN = os.Getenv("DATABASE_DSN")
c.Database.RedisURI = os.Getenv("REDIS_URI")

Expand Down Expand Up @@ -221,3 +233,31 @@ func (c *Config) IsTurnstileEnabled() bool {
func (c *Config) IsStripeEnabled() bool {
return c.Stripe.SecretKey != ""
}

// SocialProviders returns the names of the social login providers that have
// both a key and a secret configured, in the order they are registered.
func (c *Config) SocialProviders() []string {
providers := []string{}
if c.Auth.GoogleKey != "" && c.Auth.GoogleSecret != "" {
providers = append(providers, "google")
}
if c.Auth.SlackKey != "" && c.Auth.SlackSecret != "" {
providers = append(providers, "slack")
}
if c.Auth.GitHubKey != "" && c.Auth.GitHubSecret != "" {
providers = append(providers, "github")
}
return providers
}

// hasWebLoginProvider reports whether a social provider is configured that
// the web app offers a login button for. Slack is registered when configured,
// but its button is disabled in the web app, so it does not count.
func (c *Config) hasWebLoginProvider() bool {
for _, provider := range c.SocialProviders() {
if provider != "slack" {
return true
}
}
return false
}
34 changes: 34 additions & 0 deletions backend/internal/config/config_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package config

import "testing"

func TestLoad_DisablePasswordLoginRequiresProvider(t *testing.T) {
t.Setenv("ENV_STACK", "")
t.Setenv("SESSION_SECRET", "test-secret")
t.Setenv("DISABLE_PASSWORD_LOGIN", "true")
for _, name := range []string{"GOOGLE_KEY", "GOOGLE_SECRET", "SLACK_KEY", "SLACK_SECRET", "GITHUB_KEY", "GITHUB_SECRET"} {
t.Setenv(name, "")
}

if _, err := Load(); err == nil {
t.Fatal("expected Load to fail when password login is disabled and no provider is configured")
}

// Slack alone is not enough: the web app has no Slack login button.
t.Setenv("SLACK_KEY", "key")
t.Setenv("SLACK_SECRET", "secret")
if _, err := Load(); err == nil {
t.Fatal("expected Load to fail when only Slack is configured")
}

t.Setenv("GITHUB_KEY", "key")
t.Setenv("GITHUB_SECRET", "secret")

cfg, err := Load()
if err != nil {
t.Fatalf("expected Load to succeed with a configured provider, got: %v", err)
}
if !cfg.Auth.DisablePasswordLogin {
t.Fatal("expected DisablePasswordLogin to be set")
}
}
23 changes: 23 additions & 0 deletions backend/internal/handlers/handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,13 @@ func (h *AuthHandler) SocialLoginCallback(c echo.Context) error {
var isAdmin = false
// If no team invitation, we need to create a new team
if assignedTeamID == nil {
allowed, err := h.uninvitedSignupAllowed(tx)
if err != nil {
return err
}
if !allowed {
return errSignupDisabled
}
isAdmin = true
// Provider-specific handling to get team name
switch providerName {
Expand Down Expand Up @@ -304,6 +311,11 @@ func (h *AuthHandler) SocialLoginCallback(c echo.Context) error {
return nil
})

if errors.Is(err, errSignupDisabled) {
// This is a browser redirect from the provider, so send the user back to
// the login page instead of answering with a JSON error.
return c.Redirect(http.StatusFound, "/login?error=signup_disabled")
}
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
}
Expand Down Expand Up @@ -420,6 +432,14 @@ func (h *AuthHandler) ManualSignUp(c echo.Context) error {
// user insert (e.g. duplicate email) can't leave an orphan team behind.
err := h.DB.Transaction(func(tx *gorm.DB) error {
if u.TeamID == nil {
allowed, err := h.uninvitedSignupAllowed(tx)
if err != nil {
return err
}
if !allowed {
return errSignupDisabled
}

teamName := strings.TrimSpace(req.TeamName)
if teamName == "" {
teamName = fmt.Sprintf("%s-Team", u.FirstName)
Expand All @@ -436,6 +456,9 @@ func (h *AuthHandler) ManualSignUp(c echo.Context) error {
// overwriting the team_id decided above.
return tx.Omit(clause.Associations).Create(u).Error
})
if errors.Is(err, errSignupDisabled) {
return echo.NewHTTPError(http.StatusForbidden, signupDisabledMessage)
}
if errors.Is(err, gorm.ErrDuplicatedKey) {
return echo.NewHTTPError(409, "user with this email already exists")
}
Expand Down
73 changes: 73 additions & 0 deletions backend/internal/handlers/instanceConfig.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
package handlers

import (
"errors"
"net/http"

"hopp-backend/internal/models"

"github.com/labstack/echo/v4"
"gorm.io/gorm"
)

const (
signupDisabledMessage = "Sign-up is disabled on this instance. Ask a team admin for an invitation link."
passwordLoginDisabledMessage = "Email and password login is disabled on this instance."
)

// errSignupDisabled is returned from inside the sign-up transactions when an
// account without a team invitation is rejected because of DISABLE_SIGNUP.
var errSignupDisabled = errors.New("sign-up is disabled")

// InstanceConfigResponse is the public, unauthenticated description of how this
// instance is configured. Clients use it to hide flows the backend would reject.
type InstanceConfigResponse struct {
SignupEnabled bool `json:"signup_enabled"`
PasswordLoginEnabled bool `json:"password_login_enabled"`
BillingEnabled bool `json:"billing_enabled"`
AuthProviders []string `json:"auth_providers"`
}

// GetInstanceConfig returns the instance configuration relevant to clients.
func (h *AuthHandler) GetInstanceConfig(c echo.Context) error {
// Reported as enabled while the instance is empty, so the first account
// can be created from the web app even with DISABLE_SIGNUP.
signupEnabled, err := h.uninvitedSignupAllowed(h.DB)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to load instance configuration")
}

return c.JSON(http.StatusOK, InstanceConfigResponse{
SignupEnabled: signupEnabled,
PasswordLoginEnabled: !h.Config.Auth.DisablePasswordLogin,
BillingEnabled: h.Config.IsStripeEnabled(),
AuthProviders: h.Config.SocialProviders(),
})
}

// RequirePasswordLogin rejects email/password endpoints when
// DISABLE_PASSWORD_LOGIN is set.
func (h *AuthHandler) RequirePasswordLogin(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
if h.Config.Auth.DisablePasswordLogin {
return echo.NewHTTPError(http.StatusForbidden, passwordLoginDisabledMessage)
}
return next(c)
}
}

// uninvitedSignupAllowed reports whether an account may be created without a
// team invitation. With DISABLE_SIGNUP this is only the case while the instance
// has no users at all, so a fresh deployment can still be bootstrapped.
func (h *AuthHandler) uninvitedSignupAllowed(tx *gorm.DB) (bool, error) {
if !h.Config.Auth.DisableSignup {
return true, nil
}

var count int64
// Unscoped: a soft-deleted user still means the instance was bootstrapped.
if err := tx.Unscoped().Model(&models.User{}).Count(&count).Error; err != nil {
return false, err
}
return count == 0, nil
}
27 changes: 18 additions & 9 deletions backend/internal/server/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -330,11 +330,19 @@ func (s *Server) setupGothProviders() {
// Set the session secret for Goth
gothic.Store = s.Store

goth.UseProviders(
google.New(s.Config.Auth.GoogleKey, s.Config.Auth.GoogleSecret, s.Config.Auth.GoogleRedirect, "email", "profile", "openid"),
slack.New(s.Config.Auth.SlackKey, s.Config.Auth.SlackSecret, s.Config.Auth.SlackRedirect, "users:read", "users:read.email", "team:read"),
github.New(s.Config.Auth.GitHubKey, s.Config.Auth.GitHubSecret, s.Config.Auth.GitHubRedirect, "user:email", "read:user"),
)
// goth keeps providers in a package-level registry, so start clean and
// register only the ones that are configured.
goth.ClearProviders()
for _, name := range s.Config.SocialProviders() {
switch name {
case "google":
goth.UseProviders(google.New(s.Config.Auth.GoogleKey, s.Config.Auth.GoogleSecret, s.Config.Auth.GoogleRedirect, "email", "profile", "openid"))
case "slack":
goth.UseProviders(slack.New(s.Config.Auth.SlackKey, s.Config.Auth.SlackSecret, s.Config.Auth.SlackRedirect, "users:read", "users:read.email", "team:read"))
case "github":
goth.UseProviders(github.New(s.Config.Auth.GitHubKey, s.Config.Auth.GitHubSecret, s.Config.Auth.GitHubRedirect, "user:email", "read:user"))
}
}
}

func (s *Server) setupEmailClient() {
Expand Down Expand Up @@ -378,6 +386,7 @@ func (s *Server) setupRoutes() {
return c.String(200, "OK")
})
api.GET("/metrics", echoprometheus.NewHandler())
api.GET("/config", auth.GetInstanceConfig)
// Add invitation details endpoint
api.GET("/invitation-details/:uuid", auth.GetInvitationDetails)

Expand All @@ -398,10 +407,10 @@ func (s *Server) setupRoutes() {
// Authentication endpoints
api.GET("/auth/social/:provider", auth.SocialLogin)
api.GET("/auth/social/:provider/callback", auth.SocialLoginCallback)
api.POST("/sign-up", auth.ManualSignUp)
api.POST("/sign-in", auth.ManualSignIn)
api.POST("/forgot-password", auth.ForgotPassword)
api.PATCH("/reset-password/:token", auth.ResetPassword)
api.POST("/sign-up", auth.ManualSignUp, auth.RequirePasswordLogin)
api.POST("/sign-in", auth.ManualSignIn, auth.RequirePasswordLogin)
api.POST("/forgot-password", auth.ForgotPassword, auth.RequirePasswordLogin)
api.PATCH("/reset-password/:token", auth.ResetPassword, auth.RequirePasswordLogin)

// Protected API routes group
protectedAPI := api.Group("/auth", s.JwtIssuer.Middleware())
Expand Down
Loading
Loading