Files
2026-08-11 10:40:10 +01:00

219 lines
6.5 KiB
Go

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]
}