package auth import ( "context" "errors" "fmt" "strings" "time" "unicode" "github.com/andydixon/relata/internal/domain" "github.com/andydixon/relata/internal/ids" "github.com/andydixon/relata/internal/observability" ) // Provisioner turns a validated federated identity into a signed-in session. // // Each person gets a private workspace of their own, created on first sign-in, // in which they are admin. The workspace boundary is the one Relata has // enforced in storage from the beginning; this reuses it rather than inventing // a second notion of separation. type Provisioner struct { sessions *Service users domain.UserRepository workspaces domain.WorkspaceRepository } // NewProvisioner builds the provisioner. func NewProvisioner(sessions *Service, users domain.UserRepository, workspaces domain.WorkspaceRepository) *Provisioner { return &Provisioner{sessions: sessions, users: users, workspaces: workspaces} } // SignIn finds or creates the account behind an identity and starts a session. // // The session ID is new every time, so an identifier held before signing in // cannot be carried across the authentication boundary. func (p *Provisioner) SignIn(ctx context.Context, provider string, id *Identity, userAgent string) (*LoginResult, error) { if id == nil || strings.TrimSpace(id.Subject) == "" { return nil, ErrOAuthIdentity } provider = strings.TrimSpace(provider) if provider == "" { provider = "oauth" } u, err := p.findOrCreate(ctx, provider, id) if err != nil { return nil, err } if u.Disabled { // Deliberately the same error a wrong password produces. return nil, ErrInvalidCredentials } sess, token, err := p.sessions.createSession(ctx, u, userAgent) if err != nil { return nil, err } now := time.Now().UTC() u.LastLoginAt = &now // Refresh the display fields: a person who changes their name at the // provider should see the change here too. The subject, which is the key, // is never rewritten. changed := false if id.DisplayName != "" && u.DisplayName != id.DisplayName { u.DisplayName = id.DisplayName changed = true } if id.Email != "" && u.Email != id.Email { u.Email = id.Email changed = true } if err := p.users.Update(ctx, u); err != nil { observability.LoggerFrom(ctx).Warn("could not record federated sign-in", "user_id", u.ID, "error", err.Error()) } _ = changed return &LoginResult{User: u, Session: sess, Token: token}, nil } // findOrCreate resolves the account for an identity, creating it on first use. // // Provisioning is idempotent under concurrency. Two simultaneous first sign-ins // both find nothing and both try to create; the unique index on // (oauth_provider, oauth_subject) lets exactly one through, and the loser // re-reads the winner's record rather than provisioning a second workspace for // the same person. func (p *Provisioner) findOrCreate(ctx context.Context, provider string, id *Identity) (*domain.User, error) { u, err := p.users.GetByOAuthSubject(ctx, provider, id.Subject) if err == nil { return u, nil } if !errors.Is(err, domain.ErrNotFound) { return nil, err } now := time.Now().UTC() ws := &domain.Workspace{ ID: ids.New(), Name: workspaceNameFor(id), Slug: "ws-" + shortID(), CreatedAt: now, } if err := p.workspaces.Create(ctx, ws); err != nil { return nil, fmt.Errorf("create workspace for new account: %w", err) } user := &domain.User{ ID: ids.New(), Username: p.uniqueUsername(ctx, id), // No password hash: this account can never sign in locally. DisplayName: id.DisplayName, Email: id.Email, Role: domain.RoleAdmin, // admin of their own workspace, nobody else's WorkspaceID: ws.ID, OAuthProvider: provider, OAuthSubject: id.Subject, CreatedAt: now, UpdatedAt: now, } if err := p.users.Create(ctx, user); err != nil { if errors.Is(err, domain.ErrDuplicate) { // Lost the race. The winner's account is the right one; this // workspace was never referenced by anything, so it is abandoned // rather than deleted — removing it would race with the winner. if existing, e := p.users.GetByOAuthSubject(ctx, provider, id.Subject); e == nil { observability.LoggerFrom(ctx).Info( "concurrent first sign-in resolved to the existing account", "user_id", existing.ID) return existing, nil } // A duplicate that is not the subject can only be the username. user.Username = user.Username + "-" + shortID() if err2 := p.users.Create(ctx, user); err2 != nil { return nil, fmt.Errorf("create account for federated identity: %w", err2) } return user, nil } return nil, fmt.Errorf("create account for federated identity: %w", err) } observability.LoggerFrom(ctx).Info("provisioned a workspace for a new federated account", "user_id", user.ID, "workspace_id", ws.ID, "provider", provider) return user, nil } // uniqueUsername derives a readable, unique username from the identity. // // It is a label, not an identifier: the account is keyed on the provider // subject, so a collision here is cosmetic and resolved by appending. func (p *Provisioner) uniqueUsername(ctx context.Context, id *Identity) string { base := usernameSeed(id) candidate := base for attempt := 0; attempt < 5; attempt++ { if _, err := p.users.GetByUsername(ctx, candidate); errors.Is(err, domain.ErrNotFound) { return candidate } candidate = base + "-" + shortID() } return base + "-" + shortID() } // usernameSeed builds the readable part of a username from an identity. func usernameSeed(id *Identity) string { seed := id.Email if at := strings.Index(seed, "@"); at > 0 { seed = seed[:at] } if strings.TrimSpace(seed) == "" { seed = id.DisplayName } if strings.TrimSpace(seed) == "" { seed = "user" } var b strings.Builder for _, r := range strings.ToLower(seed) { switch { case unicode.IsLetter(r) || unicode.IsDigit(r): b.WriteRune(r) case r == '.' || r == '-' || r == '_': b.WriteRune('-') case unicode.IsSpace(r): b.WriteRune('-') } } out := strings.Trim(b.String(), "-") if len([]rune(out)) > 40 { out = string([]rune(out)[:40]) } if out == "" { out = "user" } return out } // workspaceNameFor labels a person's private workspace. func workspaceNameFor(id *Identity) string { who := strings.TrimSpace(id.DisplayName) if who == "" { who = strings.TrimSpace(id.Email) } if who == "" { return "Private workspace" } if len([]rune(who)) > 60 { who = string([]rune(who)[:60]) } return who + "'s workspace" } func shortID() string { return strings.ReplaceAll(ids.New(), "-", "")[:8] }