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
3 changes: 3 additions & 0 deletions extension/contract/contract.go
Original file line number Diff line number Diff line change
Expand Up @@ -333,6 +333,9 @@ func Register(
if err := dispatcher.RegisterQuery(d, c, "auth.featureToggles", 1, featureTogglesHandler(deps)); err != nil {
return fmt.Errorf("authsome/contract: register auth.featureToggles: %w", err)
}
if err := dispatcher.RegisterQuery(d, c, "plugins.list", 1, pluginsListHandler(deps)); err != nil {
return fmt.Errorf("authsome/contract: register plugins.list: %w", err)
}
if err := dispatcher.RegisterCommand(d, c, "auth.toggleFeature", 1, toggleFeatureHandler(deps)); err != nil {
return fmt.Errorf("authsome/contract: register auth.toggleFeature: %w", err)
}
Expand Down
292 changes: 277 additions & 15 deletions extension/contract/handlers_auth_pages.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,17 @@ package contract
import (
"context"
"errors"
"net/url"
"regexp"
"strings"
"sync"

authsome "github.com/xraph/authsome"
"github.com/xraph/authsome/account"
"github.com/xraph/authsome/app"
"github.com/xraph/authsome/environment"
"github.com/xraph/authsome/formconfig"
"github.com/xraph/authsome/id"
"github.com/xraph/authsome/user"

dashauth "github.com/xraph/forge/extensions/dashboard/auth"
Expand Down Expand Up @@ -211,16 +217,56 @@ func mapResetError(err error) error {
// when no users exist yet — the dashboard's AuthGate uses this to
// redirect to /setup before /login.
type SetupStatusResponse struct {
Pending bool `json:"pending"`
Pending bool `json:"pending"`
Platform *SetupPlatformDefaults `json:"platform,omitempty"`
Environment *SetupEnvironmentDefaults `json:"environment,omitempty"`
}

// SetupPlatformDefaults contains the public platform fields the anonymous
// setup form may edit. Keep this deliberately smaller than AppDetail: stored
// metadata and the publishable key must never cross this public boundary.
type SetupPlatformDefaults struct {
Name string `json:"name"`
Slug string `json:"slug"`
Logo string `json:"logo,omitempty"`
}

// SetupEnvironmentDefaults contains only the public default-environment
// fields needed to initialize first-run setup.
type SetupEnvironmentDefaults struct {
Name string `json:"name"`
Slug string `json:"slug"`
Type string `json:"type"`
IsDefault bool `json:"isDefault"`
Color string `json:"color,omitempty"`
Description string `json:"description,omitempty"`
}

// SetupInput is the wire shape for auth.setup. Mirrors the
// auth.setup-form renderer's fields.
type SetupInput struct {
Email string `json:"email"`
Password string `json:"password"`
Name string `json:"name,omitempty"`
OrganizationName string `json:"organizationName,omitempty"`
Email string `json:"email"`
Password string `json:"password"`
Name string `json:"name,omitempty"`
OrganizationName string `json:"organizationName,omitempty"`
Platform *SetupPlatformInput `json:"platform,omitempty"`
Environment *SetupEnvironmentInput `json:"environment,omitempty"`
}

type SetupPlatformInput struct {
Name string `json:"name"`
Slug string `json:"slug"`
Logo string `json:"logo,omitempty"`
Metadata map[string]string `json:"metadata,omitempty"`
}

type SetupEnvironmentInput struct {
Name string `json:"name"`
Slug string `json:"slug"`
Type string `json:"type"`
Color string `json:"color,omitempty"`
Description string `json:"description,omitempty"`
Metadata map[string]string `json:"metadata,omitempty"`
}

// SetupResponse is the auth.setup reply.
Expand All @@ -247,7 +293,36 @@ func setupStatusHandler(deps Deps) func(ctx context.Context, _ struct{}, _ contr
// already-bootstrapped deployment whose count query just hiccupped.
return SetupStatusResponse{Pending: false}, nil
}
return SetupStatusResponse{Pending: list.Total == 0}, nil
if list.Total > 0 {
return SetupStatusResponse{Pending: false}, nil
}

appID := defaultAppID(eng)
platform, err := eng.GetApp(ctx, appID)
if err != nil {
return SetupStatusResponse{}, mapEngineError(err)
}
defaultEnv, err := eng.GetDefaultEnvironment(ctx, appID)
if err != nil {
return SetupStatusResponse{}, mapEngineError(err)
}

return SetupStatusResponse{
Pending: true,
Platform: &SetupPlatformDefaults{
Name: platform.Name,
Slug: platform.Slug,
Logo: platform.Logo,
},
Environment: &SetupEnvironmentDefaults{
Name: defaultEnv.Name,
Slug: defaultEnv.Slug,
Type: string(defaultEnv.Type),
IsDefault: defaultEnv.IsDefault,
Color: defaultEnv.Color,
Description: defaultEnv.Description,
},
}, nil
}
}

Expand All @@ -260,19 +335,199 @@ func setupStatusHandler(deps Deps) func(ctx context.Context, _ struct{}, _ contr
// to create the org alongside the user. Failure to create the org
// doesn't roll back the user — admins can create their org manually
// post-setup.
const (
setupNameMax = 80
setupSlugMax = 63
setupDescriptionMax = 240
setupMetadataMax = 20
setupMetadataKeyMax = 64
setupMetadataValMax = 512
)

var setupSlugPattern = regexp.MustCompile(`^[a-z0-9]+(?:-[a-z0-9]+)*$`)

func setupBadRequest(field, message string) error {
return &contract.Error{
Code: contract.CodeBadRequest,
Message: message,
Details: map[string]any{"field": field},
}
}

func normalizeSetupMetadata(field string, metadata map[string]string) (map[string]string, error) {
if metadata == nil {
return nil, nil
}
if len(metadata) > setupMetadataMax {
return nil, setupBadRequest(field, "metadata may contain at most 20 entries")
}
normalized := make(map[string]string, len(metadata))
for rawKey, rawValue := range metadata {
key := strings.TrimSpace(rawKey)
value := strings.TrimSpace(rawValue)
if key == "" || value == "" {
return nil, setupBadRequest(field, "metadata keys and values are required")
}
if len(key) > setupMetadataKeyMax {
return nil, setupBadRequest(field, "metadata keys may contain at most 64 characters")
}
if len(value) > setupMetadataValMax {
return nil, setupBadRequest(field, "metadata values may contain at most 512 characters")
}
if _, exists := normalized[key]; exists {
return nil, setupBadRequest(field, "metadata keys must be unique")
}
normalized[key] = value
}
return normalized, nil
}

func validateSetupNameAndSlug(prefix, name, slug string) (trimmedName, trimmedSlug string, err error) {
name = strings.TrimSpace(name)
slug = strings.TrimSpace(slug)
if name == "" {
return "", "", setupBadRequest(prefix+".name", "name is required")
}
if len(name) > setupNameMax {
return "", "", setupBadRequest(prefix+".name", "name may contain at most 80 characters")
}
if slug == "" {
return "", "", setupBadRequest(prefix+".slug", "slug is required")
}
if len(slug) > setupSlugMax || !setupSlugPattern.MatchString(slug) {
return "", "", setupBadRequest(prefix+".slug", "slug must use lowercase letters, numbers, and single hyphens")
}
return name, slug, nil
}

func validSetupLogo(value string) bool {
if value == "" || (strings.HasPrefix(value, "/") && !strings.HasPrefix(value, "//")) {
return true
}
parsed, err := url.Parse(value)
return err == nil && parsed.Host != "" && (parsed.Scheme == "http" || parsed.Scheme == "https")
}

func validateSetupInput(in SetupInput) (SetupInput, error) {
in.Email = strings.ToLower(strings.TrimSpace(in.Email))
in.Name = strings.TrimSpace(in.Name)
in.OrganizationName = strings.TrimSpace(in.OrganizationName)
if in.Email == "" || in.Password == "" {
return SetupInput{}, setupBadRequest("administrator.email", "email and password are required")
}

if in.Platform != nil {
platform := *in.Platform
var err error
platform.Name, platform.Slug, err = validateSetupNameAndSlug("platform", platform.Name, platform.Slug)
if err != nil {
return SetupInput{}, err
}
platform.Logo = strings.TrimSpace(platform.Logo)
if !validSetupLogo(platform.Logo) {
return SetupInput{}, setupBadRequest("platform.logo", "logo must be an HTTP URL or a root-relative path")
}
platform.Metadata, err = normalizeSetupMetadata("platform.metadata", platform.Metadata)
if err != nil {
return SetupInput{}, err
}
in.Platform = &platform
}

if in.Environment != nil {
env := *in.Environment
var err error
env.Name, env.Slug, err = validateSetupNameAndSlug("environment", env.Name, env.Slug)
if err != nil {
return SetupInput{}, err
}
env.Type = strings.TrimSpace(env.Type)
if !environment.Type(env.Type).IsValid() {
return SetupInput{}, setupBadRequest("environment.type", "environment type is invalid")
}
env.Color = strings.TrimSpace(env.Color)
env.Description = strings.TrimSpace(env.Description)
if len(env.Description) > setupDescriptionMax {
return SetupInput{}, setupBadRequest("environment.description", "description may contain at most 240 characters")
}
env.Metadata, err = normalizeSetupMetadata("environment.metadata", env.Metadata)
if err != nil {
return SetupInput{}, err
}
in.Environment = &env
}

return in, nil
}

func mergeSetupMetadata[T ~map[string]string](existing T, incoming map[string]string) T {
if incoming == nil {
return existing
}
merged := make(T, len(existing)+len(incoming))
for key, value := range existing {
merged[key] = value
}
for key, value := range incoming {
merged[key] = value
}
return merged
}

// applySetupPlatform writes the validated platform block onto the
// bootstrapped platform app. A nil block leaves the app untouched.
func applySetupPlatform(ctx context.Context, eng *authsome.Engine, appID id.AppID, in *SetupPlatformInput) error {
if in == nil {
return nil
}
current, err := eng.GetApp(ctx, appID)
if err != nil {
return err
}
current.Name = in.Name
current.Slug = in.Slug
current.Logo = in.Logo
current.Metadata = mergeSetupMetadata[app.Metadata](current.Metadata, in.Metadata)
return eng.UpdateApp(ctx, current)
}

// applySetupEnvironment writes the validated environment block onto the
// app's default environment. Setup never creates or removes environments.
func applySetupEnvironment(ctx context.Context, eng *authsome.Engine, appID id.AppID, in *SetupEnvironmentInput) error {
if in == nil {
return nil
}
current, err := eng.GetDefaultEnvironment(ctx, appID)
if err != nil {
return err
}
current.Name = in.Name
current.Slug = in.Slug
current.Type = environment.Type(in.Type)
current.Color = in.Color
current.Description = in.Description
current.Metadata = mergeSetupMetadata[environment.Metadata](current.Metadata, in.Metadata)
return eng.UpdateEnvironment(ctx, current)
}

func setupHandler(deps Deps) func(ctx context.Context, in SetupInput, _ contract.Principal) (SetupResponse, error) {
var setupMu sync.Mutex
return func(ctx context.Context, in SetupInput, _ contract.Principal) (SetupResponse, error) {
eng := deps.Engine
if eng == nil {
return SetupResponse{}, &contract.Error{Code: contract.CodeUnavailable, Message: "auth engine not configured"}
}
email := strings.ToLower(strings.TrimSpace(in.Email))
if email == "" || in.Password == "" {
return SetupResponse{}, &contract.Error{Code: contract.CodeBadRequest, Message: "email and password are required"}
normalized, err := validateSetupInput(in)
if err != nil {
return SetupResponse{}, err
}

setupMu.Lock()
defer setupMu.Unlock()

// Refuse to bootstrap a populated deployment.
list, err := eng.AdminListUsers(ctx, &user.Query{AppID: defaultAppID(eng), Limit: 1})
appID := defaultAppID(eng)
list, err := eng.AdminListUsers(ctx, &user.Query{AppID: appID, Limit: 1})
if err != nil {
return SetupResponse{}, mapEngineError(err)
}
Expand All @@ -286,11 +541,18 @@ func setupHandler(deps Deps) func(ctx context.Context, in SetupInput, _ contract
return SetupResponse{}, &contract.Error{Code: contract.CodeInternal, Message: "no http context (forge >= dashauth.WithHTTP required)"}
}

first, last := splitName(in.Name)
if err = applySetupPlatform(ctx, eng, appID, normalized.Platform); err != nil {
return SetupResponse{}, mapEngineError(err)
}
if err = applySetupEnvironment(ctx, eng, appID, normalized.Environment); err != nil {
return SetupResponse{}, mapEngineError(err)
}

first, last := splitName(normalized.Name)
req := &account.SignUpRequest{
AppID: defaultAppID(eng),
Email: email,
Password: in.Password,
AppID: appID,
Email: normalized.Email,
Password: normalized.Password,
FirstName: first,
LastName: last,
IPAddress: clientIP(httpReq),
Expand All @@ -308,7 +570,7 @@ func setupHandler(deps Deps) func(ctx context.Context, in SetupInput, _ contract
// surface — we don't know at compile time whether the org
// plugin is loaded. Skip it for now and let admins create their
// org through the dashboard's /organizations page post-setup.
_ = in.OrganizationName
_ = normalized.OrganizationName

subject := ""
if u != nil {
Expand Down
Loading
Loading