diff --git a/cmd/backend/main.go b/cmd/backend/main.go
index 90e81853..ea39dfc4 100644
--- a/cmd/backend/main.go
+++ b/cmd/backend/main.go
@@ -40,6 +40,7 @@ import (
"github.com/warmbly/warmbly/internal/app/bootstrap"
"github.com/warmbly/warmbly/internal/app/campaign"
"github.com/warmbly/warmbly/internal/app/cipher"
+ "github.com/warmbly/warmbly/internal/app/cliauth"
"github.com/warmbly/warmbly/internal/app/cloudlink"
"github.com/warmbly/warmbly/internal/app/compose"
"github.com/warmbly/warmbly/internal/app/contact"
@@ -162,6 +163,7 @@ func main() {
var emailService email.EmailService
var poolLinkService poollink.Service
var cloudLinkService cloudlink.Service
+ var cliAuthService cliauth.Service
var campaignService campaign.CampaignService
var analyticsService analytics.AnalyticsService
var rateLimitService ratelimit.RateLimitService
@@ -1256,6 +1258,9 @@ func main() {
leadSyncServiceForHandler = leadsync.NewService(leadSyncRepository, integrationServiceForHandler, contactService)
apiKeyService = apikey.NewService(cache, apiKeyRepository)
+ // `warmbly auth login`: the browser approval mints an ordinary API key
+ // through the service above, so it has to be built after it.
+ cliAuthService = cliauth.NewService(repository.NewCLIAuthRepository(primaryDB.Pool), apiKeyService, organizationService, userService, organizationRepository)
crmService = crm.NewService(crmRepository)
teamRepository := repository.NewTeamRepository(primaryDB.Pool)
teamService = team.NewService(teamRepository)
@@ -1896,6 +1901,7 @@ func main() {
PoolLinkService: poolLinkService,
CloudLinkService: cloudLinkService,
+ CLIAuthService: cliAuthService,
TokenService: tokenService,
PasskeyService: passkeyService,
diff --git a/internal/api/handler/api_key.go b/internal/api/handler/api_key.go
index db627213..e7d5ed79 100644
--- a/internal/api/handler/api_key.go
+++ b/internal/api/handler/api_key.go
@@ -171,6 +171,37 @@ func (h *Handler) RevokeAPIKey(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"status": "revoked"})
}
+// RevokeOwnAPIKey revokes the key the request was made with.
+//
+// Deliberately outside the API_KEYS scope gate: a credential must always be
+// able to end itself. Requiring a privilege to sign out means a read-only key
+// on a laptop someone is handing back stays live, which is the opposite of
+// what a `warmbly auth logout` promises.
+func (h *Handler) RevokeOwnAPIKey(c *gin.Context) {
+ keyID := middleware.GetAPIKeyID(c)
+ if keyID == nil {
+ errx.JSON(c, errx.New(errx.BadRequest, "this endpoint revokes the API key it is called with, and this request did not use one"))
+ return
+ }
+ orgID := middleware.GetOrganizationID(c)
+ if orgID == nil {
+ errx.JSON(c, errx.New(errx.BadRequest, "no organization selected"))
+ return
+ }
+
+ reason := c.Query("reason")
+ if reason == "" {
+ reason = "Revoked by the credential itself"
+ }
+ if xerr := h.APIKeyService.Revoke(c.Request.Context(), *orgID, *keyID, reason); xerr != nil {
+ errx.JSON(c, xerr)
+ return
+ }
+
+ h.auditOrg(c, models.AuditActionRevoke, models.AuditEntityAPIKey, keyID, nil, map[string]string{"self": "true"})
+ c.JSON(http.StatusOK, gin.H{"status": "revoked"})
+}
+
// ListAPIPermissions lists all available API permissions
// GET /api-keys/permissions
func (h *Handler) ListAPIPermissions(c *gin.Context) {
diff --git a/internal/api/handler/auth_config.go b/internal/api/handler/auth_config.go
index 05a1e292..888afa53 100644
--- a/internal/api/handler/auth_config.go
+++ b/internal/api/handler/auth_config.go
@@ -72,6 +72,18 @@ type DeploymentAuthConfig struct {
// 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"`
+
+ // WebsocketURL is the realtime gateway. Served here because a developer
+ // client (the CLI's event stream, an SDK) has no other way to find the
+ // socket on a self-hosted instance. Empty when the instance runs no
+ // realtime service.
+ WebsocketURL string `json:"websocket_url,omitempty"`
+
+ // AppURL is the dashboard origin, the same one every emailed link is built
+ // from. A client that wants to send someone to a page (the CLI's `browse`,
+ // a chat integration) cannot derive it: on a self-hosted instance the host
+ // layout is whatever the operator chose.
+ AppURL string `json:"app_url,omitempty"`
}
// accountsDocsURL is the page every registration refusal points at.
@@ -104,5 +116,7 @@ func (h *Handler) AuthConfig(c *gin.Context) {
SetupRequired: h.BootstrapService != nil && h.BootstrapService.Required(c.Request.Context()),
InvitesRequired: registration == config.RegistrationInviteOnly,
DocsURL: accountsDocsURL,
+ WebsocketURL: config.WebsocketURL(),
+ AppURL: config.AppBaseURL(),
})
}
diff --git a/internal/api/handler/cli_auth.go b/internal/api/handler/cli_auth.go
new file mode 100644
index 00000000..3cf2c2ae
--- /dev/null
+++ b/internal/api/handler/cli_auth.go
@@ -0,0 +1,126 @@
+package handler
+
+import (
+ "net/http"
+
+ "github.com/gin-gonic/gin"
+ "github.com/google/uuid"
+
+ "github.com/warmbly/warmbly/internal/api/middleware"
+ "github.com/warmbly/warmbly/internal/errx"
+ "github.com/warmbly/warmbly/internal/models"
+)
+
+// Device-code sign-in for the `warmbly` CLI. The public half (start, poll) is
+// what the CLI calls; the session half is the browser approval screen.
+
+func (h *Handler) cliAuthReady(c *gin.Context) bool {
+ if h.CLIAuthService == nil {
+ errx.JSON(c, errx.New(errx.NotImplemented, "CLI sign-in is not enabled on this instance"))
+ return false
+ }
+ return true
+}
+
+// CLIAuthStart opens a handshake for a CLI that holds no key yet.
+func (h *Handler) CLIAuthStart(c *gin.Context) {
+ if !h.cliAuthReady(c) {
+ return
+ }
+ var req models.CLIAuthStartRequest
+ if err := c.ShouldBindJSON(&req); err != nil {
+ errx.JSON(c, errx.New(errx.BadRequest, "invalid request body"))
+ return
+ }
+ res, xerr := h.CLIAuthService.StartCode(c.Request.Context(), req)
+ if xerr != nil {
+ errx.JSON(c, xerr)
+ return
+ }
+ c.JSON(http.StatusCreated, res)
+}
+
+// CLIAuthPoll is polled by the CLI until a member approves the code. The key is
+// handed out exactly once, on the poll that follows approval.
+func (h *Handler) CLIAuthPoll(c *gin.Context) {
+ if !h.cliAuthReady(c) {
+ return
+ }
+ var req struct {
+ DeviceCode string `json:"device_code"`
+ }
+ if err := c.ShouldBindJSON(&req); err != nil || req.DeviceCode == "" {
+ errx.JSON(c, errx.New(errx.BadRequest, "device_code is required"))
+ return
+ }
+ res, xerr := h.CLIAuthService.PollCode(c.Request.Context(), req.DeviceCode)
+ if xerr != nil {
+ errx.JSON(c, xerr)
+ return
+ }
+ c.JSON(http.StatusOK, res)
+}
+
+// CLIAuthDescribeCode shows the approving member what they are authorizing.
+func (h *Handler) CLIAuthDescribeCode(c *gin.Context) {
+ if !h.cliAuthReady(c) {
+ return
+ }
+ code, xerr := h.CLIAuthService.DescribeCode(c.Request.Context(), c.Param("code"))
+ if xerr != nil {
+ errx.JSON(c, xerr)
+ return
+ }
+ c.JSON(http.StatusOK, code)
+}
+
+// CLIAuthApproveCode mints the key into the workspace named in the body, not
+// the session's, because a member with several workspaces picks on the screen.
+func (h *Handler) CLIAuthApproveCode(c *gin.Context) {
+ if !h.cliAuthReady(c) {
+ return
+ }
+ userID, err := middleware.GetUserUUID(c)
+ if err != nil {
+ errx.JSON(c, errx.ErrUnauthorized)
+ return
+ }
+ var req models.CLIAuthApproveRequest
+ if err := c.ShouldBindJSON(&req); err != nil {
+ errx.JSON(c, errx.New(errx.BadRequest, "invalid request body"))
+ return
+ }
+ orgID, perr := uuid.Parse(req.OrganizationID)
+ if perr != nil {
+ if sessionOrg := middleware.GetOrganizationID(c); sessionOrg != nil {
+ orgID = *sessionOrg
+ } else {
+ errx.JSON(c, errx.ErrNoOrganization)
+ return
+ }
+ }
+
+ code, xerr := h.CLIAuthService.ApproveCode(c.Request.Context(), c.Param("code"), orgID, userID)
+ if xerr != nil {
+ errx.JSON(c, xerr)
+ return
+ }
+ // Logged against the org that was picked on screen, which is not always the
+ // session's, so this cannot go through auditOrg.
+ h.AuditService.LogAction(c.Request.Context(), orgID, userID, models.AuditActionCreate, models.AuditEntityAPIKey, code.APIKeyID,
+ c.ClientIP(), c.Request.UserAgent(), nil, map[string]string{"source": "cli", "client": code.ClientName, "hostname": code.Hostname})
+ c.JSON(http.StatusOK, code)
+}
+
+// CLIAuthDenyCode declines the request. Deliberately not audited: nothing was
+// created, and a denial is not a change to the workspace.
+func (h *Handler) CLIAuthDenyCode(c *gin.Context) {
+ if !h.cliAuthReady(c) {
+ return
+ }
+ if xerr := h.CLIAuthService.DenyCode(c.Request.Context(), c.Param("code")); xerr != nil {
+ errx.JSON(c, xerr)
+ return
+ }
+ c.JSON(http.StatusOK, gin.H{"ok": true})
+}
diff --git a/internal/api/handler/handler.go b/internal/api/handler/handler.go
index 82102b2a..423bfbde 100644
--- a/internal/api/handler/handler.go
+++ b/internal/api/handler/handler.go
@@ -14,6 +14,7 @@ import (
"github.com/warmbly/warmbly/internal/app/behavior"
"github.com/warmbly/warmbly/internal/app/bootstrap"
"github.com/warmbly/warmbly/internal/app/campaign"
+ "github.com/warmbly/warmbly/internal/app/cliauth"
"github.com/warmbly/warmbly/internal/app/cloudlink"
"github.com/warmbly/warmbly/internal/app/compose"
"github.com/warmbly/warmbly/internal/app/contact"
@@ -311,6 +312,9 @@ type Handler struct {
PoolLinkService poollink.Service
CloudLinkService cloudlink.Service
+ // Device-code sign-in for the `warmbly` CLI. Nil-safe: routes answer 501.
+ CLIAuthService cliauth.Service
+
// Infrastructure liveness probes for the admin System Status page.
// Wired in cmd/backend/main.go where the concrete clients live.
SystemChecker *sysstatus.Checker
diff --git a/internal/api/routes.go b/internal/api/routes.go
index 4da4cae3..9857e6cb 100644
--- a/internal/api/routes.go
+++ b/internal/api/routes.go
@@ -240,6 +240,17 @@ func Run(
poolLinkPublic.POST("/poll", h.PoolLinkPoll)
}
+ // `warmbly auth login`. Unauthenticated by nature (the CLI has no key
+ // yet), so it shares the per-IP budget with the other public handshakes.
+ // Registered above the /auth group because that group's own rate limiter
+ // is tuned for password work, and a poll every three seconds is not that.
+ cliAuthPublic := v1.Group("/auth/cli")
+ cliAuthPublic.Use(m.AuthIPRateLimitMiddleware())
+ {
+ cliAuthPublic.POST("/code", h.CLIAuthStart)
+ cliAuthPublic.POST("/poll", h.CLIAuthPoll)
+ }
+
auth := v1.Group("/auth")
// Every unauthenticated auth route shares one per-IP budget. Nothing
// throttled these before: RateLimitMiddleware is keyed on the user id and
@@ -749,6 +760,11 @@ func Run(
// API key management. JWT users need PermManageAPIKeys; API keys
// need the APIPermAPIKeys self-service bit. This lets an integration
// rotate its own keys without going through the dashboard.
+ // Self-revocation, outside the API_KEYS gate below on purpose: any
+ // valid key may end itself, which is what makes signing a machine
+ // out actually end its access.
+ protected.DELETE("/api-keys/self", m.RequireOrganization(), m.RateLimitMiddleware(models.RateLimitWrite), h.RevokeOwnAPIKey)
+
apiKeys := protected.Group("/api-keys")
apiKeys.Use(m.RequireOrganization(), m.RequireAccess(models.PermManageAPIKeys, models.APIPermAPIKeys))
apiKeys.Use(m.RateLimitMiddleware(models.RateLimitWrite))
@@ -1218,6 +1234,18 @@ func Run(
poolLink.GET("/instances", m.RequireOrganization(), m.RequirePermission(models.PermManageSettings), h.PoolLinkListInstances)
poolLink.DELETE("/instances/:id", m.RequireOrganization(), m.RequirePermission(models.PermManageSettings), h.PoolLinkRevokeInstance)
}
+
+ // Browser half of `warmbly auth login`: a member reviews the code
+ // and approves it into one of their workspaces. Session-only, like
+ // the pool link approval, because approving mints a credential and
+ // an API key must not be able to mint another CLI's key.
+ cliAuth := jwtOnly.Group("/auth/cli")
+ cliAuth.Use(m.RateLimitMiddleware(models.RateLimitWrite))
+ {
+ cliAuth.GET("/codes/:code", h.CLIAuthDescribeCode)
+ cliAuth.POST("/codes/:code/approve", h.CLIAuthApproveCode)
+ cliAuth.POST("/codes/:code/deny", h.CLIAuthDenyCode)
+ }
// The linked instance's own surface, authenticated by its token.
poolLinkInstance := base.Group("/pool-link/instance")
poolLinkInstance.Use(m.PoolLinkAuthMiddleware())
diff --git a/internal/app/cliauth/service.go b/internal/app/cliauth/service.go
new file mode 100644
index 00000000..1a07b3c3
--- /dev/null
+++ b/internal/app/cliauth/service.go
@@ -0,0 +1,286 @@
+// Package cliauth is the device-code sign-in the `warmbly` CLI uses.
+//
+// The CLI has no credential of its own, so it opens a handshake, shows the
+// user an eight character code, and polls. A signed-in member approves the
+// code in the browser, and the approval mints an ordinary API key through the
+// existing service: same hash, same scopes, same revocation, visible under
+// Settings > API keys like every other key. Nothing here is a new credential
+// type and nothing here is a new authentication path.
+package cliauth
+
+import (
+ "context"
+ "crypto/rand"
+ "crypto/sha256"
+ "encoding/base64"
+ "encoding/hex"
+ "fmt"
+ "net/url"
+ "strings"
+ "time"
+
+ "github.com/google/uuid"
+
+ "github.com/warmbly/warmbly/internal/app/apikey"
+ "github.com/warmbly/warmbly/internal/app/organization"
+ "github.com/warmbly/warmbly/internal/app/user"
+ "github.com/warmbly/warmbly/internal/config"
+ "github.com/warmbly/warmbly/internal/errx"
+ "github.com/warmbly/warmbly/internal/models"
+ "github.com/warmbly/warmbly/internal/repository"
+)
+
+var (
+ ErrCodeNotFound = errx.NewWithIdentifier(errx.NotFound, "cli_auth_code_not_found", "That code is unknown or has expired. Run `warmbly auth login` again for a fresh one.")
+ ErrCodeNotPending = errx.NewWithIdentifier(errx.Conflict, "cli_auth_code_used", "That code has already been used.")
+ ErrBadRequest = errx.NewWithIdentifier(errx.BadRequest, "cli_auth_request", "device_code is required.")
+ ErrBadScopes = errx.NewWithIdentifier(errx.BadRequest, "cli_auth_scopes", "The requested scopes include bits this instance does not grant.")
+ ErrForbidden = errx.NewWithIdentifier(errx.Forbidden, "cli_auth_forbidden", "Managing API keys is required to authorize a CLI in this workspace.")
+)
+
+type Service interface {
+ // StartCode opens a handshake for a CLI that holds no key yet.
+ StartCode(ctx context.Context, req models.CLIAuthStartRequest) (*models.CLIAuthStartResponse, *errx.Error)
+ // PollCode is what the CLI calls until a member decides.
+ PollCode(ctx context.Context, deviceCode string) (*models.CLIAuthPollResponse, *errx.Error)
+ // DescribeCode is what the approving member sees before deciding.
+ DescribeCode(ctx context.Context, userCode string) (*models.CLIAuthCode, *errx.Error)
+ // ApproveCode mints the key into the named workspace.
+ ApproveCode(ctx context.Context, userCode string, orgID, userID uuid.UUID) (*models.CLIAuthCode, *errx.Error)
+ DenyCode(ctx context.Context, userCode string) *errx.Error
+}
+
+type service struct {
+ repo repository.CLIAuthRepository
+ keys apikey.APIKeyService
+ orgs organization.OrganizationService
+ users user.UserService
+ orgRep repository.OrganizationRepository
+}
+
+func NewService(
+ repo repository.CLIAuthRepository,
+ keys apikey.APIKeyService,
+ orgs organization.OrganizationService,
+ users user.UserService,
+ orgRep repository.OrganizationRepository,
+) Service {
+ return &service{repo: repo, keys: keys, orgs: orgs, users: users, orgRep: orgRep}
+}
+
+// Unambiguous alphabet: no 0/O, 1/I/L. Same as the pool link handshake, because
+// both codes get read off one screen and typed into another.
+const userCodeAlphabet = "ABCDEFGHJKMNPQRSTUVWXYZ23456789"
+
+func randomUserCode() (string, error) {
+ b := make([]byte, 8)
+ if _, err := rand.Read(b); err != nil {
+ return "", err
+ }
+ out := make([]byte, 0, 9)
+ for i, v := range b {
+ if i == 4 {
+ out = append(out, '-')
+ }
+ out = append(out, userCodeAlphabet[int(v)%len(userCodeAlphabet)])
+ }
+ return string(out), nil
+}
+
+func randomDeviceCode() (string, error) {
+ b := make([]byte, 32)
+ if _, err := rand.Read(b); err != nil {
+ return "", err
+ }
+ return base64.RawURLEncoding.EncodeToString(b), nil
+}
+
+func hashDeviceCode(t string) string {
+ sum := sha256.Sum256([]byte(t))
+ return hex.EncodeToString(sum[:])
+}
+
+// NormalizeUserCode accepts any case, with or without the dash, so a user who
+// retypes the code by hand is not punished for the formatting.
+func NormalizeUserCode(raw string) string {
+ raw = strings.ToUpper(strings.TrimSpace(raw))
+ raw = strings.ReplaceAll(raw, "-", "")
+ raw = strings.ReplaceAll(raw, " ", "")
+ if len(raw) != 8 {
+ return raw
+ }
+ return raw[:4] + "-" + raw[4:]
+}
+
+func clip(s string, n int) string {
+ s = strings.TrimSpace(s)
+ if len(s) > n {
+ return s[:n]
+ }
+ return s
+}
+
+func (s *service) StartCode(ctx context.Context, req models.CLIAuthStartRequest) (*models.CLIAuthStartResponse, *errx.Error) {
+ req.ClientName = clip(req.ClientName, 60)
+ if req.ClientName == "" {
+ req.ClientName = "Warmbly CLI"
+ }
+ req.Hostname = clip(req.Hostname, 80)
+ req.CLIVersion = clip(req.CLIVersion, 40)
+
+ // An unknown bit would grant a scope the approval screen never showed.
+ if req.Scopes&^models.AllAPIPermissionsMask != 0 {
+ return nil, ErrBadScopes
+ }
+ if req.Scopes == 0 {
+ req.Scopes = models.APIPermFullAccess
+ }
+
+ deviceCode, err := randomDeviceCode()
+ if err != nil {
+ return nil, errx.InternalError()
+ }
+ _ = s.repo.DeleteExpiredCodes(ctx)
+
+ var code *models.CLIAuthCode
+ for attempt := 0; attempt < 3; attempt++ {
+ userCode, uerr := randomUserCode()
+ if uerr != nil {
+ return nil, errx.InternalError()
+ }
+ created, cerr := s.repo.CreateCode(ctx, hashDeviceCode(deviceCode), userCode, req, time.Now().Add(config.CLIAuthCodeTTLMinutes*time.Minute))
+ if cerr == nil && created != nil {
+ code = created
+ break
+ }
+ // A user-code collision is the only expected failure; retry with a new one.
+ }
+ if code == nil {
+ return nil, errx.InternalError()
+ }
+
+ verify := config.AppBaseURL() + "/cli"
+ return &models.CLIAuthStartResponse{
+ DeviceCode: deviceCode,
+ UserCode: code.UserCode,
+ VerificationURL: verify,
+ VerificationURLComplete: verify + "?code=" + url.QueryEscape(code.UserCode),
+ ExpiresIn: config.CLIAuthCodeTTLMinutes * 60,
+ Interval: config.CLIAuthPollIntervalSeconds,
+ }, nil
+}
+
+func (s *service) PollCode(ctx context.Context, deviceCode string) (*models.CLIAuthPollResponse, *errx.Error) {
+ deviceCode = strings.TrimSpace(deviceCode)
+ if deviceCode == "" {
+ return nil, ErrBadRequest
+ }
+ code, secret, err := s.repo.ClaimCode(ctx, hashDeviceCode(deviceCode))
+ if err != nil {
+ return nil, errx.InternalError()
+ }
+ if code == nil {
+ // Expired and unknown are the same answer on purpose: a poller that
+ // can tell them apart can probe for live handshakes.
+ return nil, ErrCodeNotFound
+ }
+ res := &models.CLIAuthPollResponse{Status: code.Status}
+ if secret == "" {
+ return res, nil
+ }
+
+ res.Token = secret
+ res.Scopes = code.Scopes
+ res.ScopeNames = code.ScopeNames
+ res.OrganizationID = code.OrganizationID
+
+ // Identity is a convenience for `warmbly auth status`, not part of the
+ // grant, so a lookup failure must not lose the user their token.
+ if key, kerr := s.keys.ValidateKey(ctx, secret); kerr == nil && key != nil {
+ res.APIKeyID = &key.ID
+ res.UserID = &key.UserID
+ if u, uerr := s.users.GetUser(ctx, key.UserID); uerr == nil && u != nil {
+ res.UserEmail = u.Email
+ res.UserName = strings.TrimSpace(u.FirstName + " " + u.LastName)
+ }
+ }
+ if code.OrganizationID != nil && s.orgRep != nil {
+ if org, oerr := s.orgRep.GetByID(ctx, *code.OrganizationID); oerr == nil && org != nil {
+ res.OrganizationName = org.Name
+ }
+ }
+ return res, nil
+}
+
+func (s *service) DescribeCode(ctx context.Context, userCode string) (*models.CLIAuthCode, *errx.Error) {
+ code, err := s.repo.GetCodeByUserCode(ctx, NormalizeUserCode(userCode))
+ if err != nil {
+ return nil, errx.InternalError()
+ }
+ if code == nil {
+ return nil, ErrCodeNotFound
+ }
+ return code, nil
+}
+
+func (s *service) ApproveCode(ctx context.Context, userCode string, orgID, userID uuid.UUID) (*models.CLIAuthCode, *errx.Error) {
+ userCode = NormalizeUserCode(userCode)
+ code, xerr := s.DescribeCode(ctx, userCode)
+ if xerr != nil {
+ return nil, xerr
+ }
+ if code.Status != models.CLIAuthCodePending {
+ return nil, ErrCodeNotPending
+ }
+
+ allowed, xerr := s.orgs.HasPermission(ctx, orgID, userID, models.PermManageAPIKeys)
+ if xerr != nil {
+ return nil, xerr
+ }
+ if !allowed {
+ return nil, ErrForbidden
+ }
+
+ // The key is named for the machine that asked, so Settings > API keys shows
+ // which laptop a key belongs to and revoking the right one is possible.
+ name := code.ClientName
+ if code.Hostname != "" {
+ name += " on " + code.Hostname
+ }
+ desc := fmt.Sprintf("Created by `warmbly auth login` for code %s", code.UserCode)
+ created, xerr := s.keys.Create(ctx, orgID, userID, &models.CreateAPIKey{
+ Name: clip(name, 255),
+ Description: &desc,
+ Permissions: code.Scopes,
+ })
+ if xerr != nil {
+ return nil, xerr
+ }
+
+ ok, err := s.repo.ApproveCode(ctx, userCode, orgID, userID, created.ID, created.Secret)
+ if err != nil {
+ return nil, errx.InternalError()
+ }
+ if !ok {
+ // Someone approved or denied between the read and the write. The key
+ // would otherwise be an orphan nobody asked for.
+ _ = s.keys.Revoke(ctx, orgID, created.ID, "cli authorization was resolved elsewhere")
+ return nil, ErrCodeNotPending
+ }
+
+ code.Status = models.CLIAuthCodeApproved
+ code.OrganizationID = &orgID
+ code.APIKeyID = &created.ID
+ return code, nil
+}
+
+func (s *service) DenyCode(ctx context.Context, userCode string) *errx.Error {
+ ok, err := s.repo.DenyCode(ctx, NormalizeUserCode(userCode))
+ if err != nil {
+ return errx.InternalError()
+ }
+ if !ok {
+ return ErrCodeNotFound
+ }
+ return nil
+}
diff --git a/internal/app/orgtransfer/spec.go b/internal/app/orgtransfer/spec.go
index aa4892cb..e6df3ba9 100644
--- a/internal/app/orgtransfer/spec.go
+++ b/internal/app/orgtransfer/spec.go
@@ -786,6 +786,7 @@ var ExcludedTables = map[string]string{
"dedicated_worker_assignments": "Worker topology, which is a property of the instance rather than the workspace.",
"warmup_pools": "Instance-global pool definitions shared by every workspace on the instance.",
"pool_link_codes": "In-flight link handshakes between a self-hosted instance and this cloud, valid for minutes.",
+ "cli_auth_codes": "In-flight `warmbly auth login` handshakes, valid for minutes. The API key an approval mints does travel, with the api_keys rows.",
"pool_link_instances": "Self-hosted instances linked to this workspace's pool allowance. The token hash only authenticates against this instance, and the enrolled mailboxes are mirrors of mailboxes that live elsewhere.",
"pool_link_mailboxes": "Which mailbox rows are warmup-only mirrors for a linked instance. They follow pool_link_instances, which does not travel.",
"cloud_link": "This instance's own link to Warmbly Cloud: an instance property, not workspace data, and its token would be wrong on any other instance.",
diff --git a/internal/config/constants.go b/internal/config/constants.go
index 7781394e..6308e31a 100644
--- a/internal/config/constants.go
+++ b/internal/config/constants.go
@@ -282,6 +282,11 @@ const (
WarmupPoolFallbackMinAgeDays = 3 // other-tier mailboxes must be this old before they fill in
DailyThrottleNewOrgs = 3 // new workspaces per owner per day
+ // CLI sign-in handshake (`warmbly auth login`). Shorter-lived than the pool
+ // link handshake because a person is watching the terminal while it runs.
+ CLIAuthCodeTTLMinutes = 10
+ CLIAuthPollIntervalSeconds = 3
+
// DailyThrottleNewScheduledSends caps how many NEW scheduled-send
// schedules a single user can create in a rolling 24h window. The
// real defense against burst abuse — someone writing a loop that
diff --git a/internal/config/endpoints.go b/internal/config/endpoints.go
index de68563d..187718f8 100644
--- a/internal/config/endpoints.go
+++ b/internal/config/endpoints.go
@@ -23,6 +23,23 @@ func AppBaseURL() string {
return "https://app.warmbly.com"
}
+// WebsocketURL is the realtime gateway clients connect to. It is deployment
+// configuration rather than a secret, which is why GET /v1/auth/config serves
+// it: a CLI or a developer client cannot otherwise find the socket on a
+// self-hosted instance, where the host layout is whatever the operator chose.
+func WebsocketURL() string {
+ v := strings.TrimRight(strings.TrimSpace(os.Getenv("WEBSOCKET_URL")), "/")
+ if v == "" {
+ return ""
+ }
+ // The variable is written both ways in the wild: with the Phoenix path and
+ // without. Clients want the full endpoint.
+ if !strings.Contains(v, "/socket") {
+ v += "/socket/websocket"
+ }
+ return v
+}
+
func GetPasswordResetURL(sessionToken string) string {
return AppBaseURL() + "/auth/reset-password/confirm?session=" + url.QueryEscape(sessionToken)
}
diff --git a/internal/infrastructure/db/migrations/000128_cli_auth.down.sql b/internal/infrastructure/db/migrations/000128_cli_auth.down.sql
new file mode 100644
index 00000000..fe55061e
--- /dev/null
+++ b/internal/infrastructure/db/migrations/000128_cli_auth.down.sql
@@ -0,0 +1 @@
+DROP TABLE IF EXISTS cli_auth_codes;
diff --git a/internal/infrastructure/db/migrations/000128_cli_auth.up.sql b/internal/infrastructure/db/migrations/000128_cli_auth.up.sql
new file mode 100644
index 00000000..35e772c2
--- /dev/null
+++ b/internal/infrastructure/db/migrations/000128_cli_auth.up.sql
@@ -0,0 +1,28 @@
+-- CLI sign-in: the `warmbly` CLI has no credential of its own, so it opens a
+-- device-code handshake, a signed-in member approves it in the browser, and the
+-- approval mints an ordinary API key. The key is the credential; this table
+-- only carries the handshake and is empty within minutes.
+--
+-- Same shape as pool_link_codes, one row per `warmbly auth login`.
+
+CREATE TABLE IF NOT EXISTS cli_auth_codes (
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
+ device_code_hash text NOT NULL UNIQUE,
+ user_code text NOT NULL UNIQUE,
+ -- What the CLI asked for, shown on the approval screen.
+ client_name text NOT NULL DEFAULT '',
+ hostname text NOT NULL DEFAULT '',
+ cli_version text NOT NULL DEFAULT '',
+ scopes bigint NOT NULL DEFAULT 0,
+ status text NOT NULL DEFAULT 'pending'
+ CHECK (status IN ('pending', 'approved', 'claimed', 'denied')),
+ organization_id uuid REFERENCES organizations (id) ON DELETE CASCADE,
+ approved_by uuid REFERENCES users (id) ON DELETE SET NULL,
+ api_key_id uuid REFERENCES api_keys (id) ON DELETE SET NULL,
+ -- The minted secret, held only between approval and the CLI's next poll.
+ api_key_secret text,
+ expires_at timestamptz NOT NULL,
+ created_at timestamptz NOT NULL DEFAULT now()
+);
+
+CREATE INDEX IF NOT EXISTS idx_cli_auth_codes_expires ON cli_auth_codes (expires_at);
diff --git a/internal/models/cli_auth.go b/internal/models/cli_auth.go
new file mode 100644
index 00000000..4deca4a0
--- /dev/null
+++ b/internal/models/cli_auth.go
@@ -0,0 +1,91 @@
+package models
+
+import (
+ "time"
+
+ "github.com/google/uuid"
+)
+
+// CLI sign-in: `warmbly auth login` opens a device-code handshake, a member
+// approves it in the browser, and the approval mints an ordinary API key.
+
+// CLIAuthCodeStatus is the lifecycle of one handshake.
+type CLIAuthCodeStatus string
+
+const (
+ CLIAuthCodePending CLIAuthCodeStatus = "pending"
+ CLIAuthCodeApproved CLIAuthCodeStatus = "approved"
+ // Claimed: the CLI has fetched its key, the code is spent.
+ CLIAuthCodeClaimed CLIAuthCodeStatus = "claimed"
+ CLIAuthCodeDenied CLIAuthCodeStatus = "denied"
+)
+
+// CLIAuthCode is what the approving member is shown before deciding.
+type CLIAuthCode struct {
+ ID uuid.UUID `json:"id"`
+ UserCode string `json:"user_code"`
+ ClientName string `json:"client_name"`
+ Hostname string `json:"hostname"`
+ CLIVersion string `json:"cli_version"`
+ Scopes uint64 `json:"scopes"`
+ ScopeNames []string `json:"scope_names"`
+ Status CLIAuthCodeStatus `json:"status"`
+ OrganizationID *uuid.UUID `json:"organization_id,omitempty"`
+ // APIKeyID is set only on the approval response: the key that was minted.
+ APIKeyID *uuid.UUID `json:"api_key_id,omitempty"`
+ ExpiresAt time.Time `json:"expires_at"`
+ CreatedAt time.Time `json:"created_at"`
+}
+
+// CLIAuthStartRequest is what the CLI sends to open a handshake. Every field is
+// display-only except Scopes, which bounds the key the approval mints.
+type CLIAuthStartRequest struct {
+ ClientName string `json:"client_name"`
+ Hostname string `json:"hostname"`
+ CLIVersion string `json:"cli_version"`
+ Scopes uint64 `json:"scopes"`
+}
+
+// CLIAuthStartResponse is RFC 8628 shaped, so a generic device-flow client works.
+type CLIAuthStartResponse struct {
+ DeviceCode string `json:"device_code"`
+ UserCode string `json:"user_code"`
+ VerificationURL string `json:"verification_uri"`
+ // VerificationURLComplete carries the code, so the browser needs no typing.
+ VerificationURLComplete string `json:"verification_uri_complete"`
+ ExpiresIn int `json:"expires_in"`
+ Interval int `json:"interval"`
+}
+
+// CLIAuthPollResponse answers one poll. Status is the only field always set;
+// the key fields arrive exactly once, on the poll that claims an approved code.
+type CLIAuthPollResponse struct {
+ Status CLIAuthCodeStatus `json:"status"`
+
+ Token string `json:"token,omitempty"`
+ APIKeyID *uuid.UUID `json:"api_key_id,omitempty"`
+ Scopes uint64 `json:"scopes,omitempty"`
+ ScopeNames []string `json:"scope_names,omitempty"`
+ UserID *uuid.UUID `json:"user_id,omitempty"`
+ UserEmail string `json:"user_email,omitempty"`
+ UserName string `json:"user_name,omitempty"`
+ OrganizationID *uuid.UUID `json:"organization_id,omitempty"`
+ OrganizationName string `json:"organization_name,omitempty"`
+}
+
+// CLIAuthApproveRequest names the workspace the key is minted in.
+type CLIAuthApproveRequest struct {
+ OrganizationID string `json:"organization_id"`
+}
+
+// APIScopeNames turns a permission bitmask into the scope names the CLI and the
+// approval screen show, in the canonical order of AllAPIPermissions.
+func APIScopeNames(mask uint64) []string {
+ names := make([]string, 0, len(AllAPIPermissions))
+ for _, p := range AllAPIPermissions {
+ if mask&p.Value == p.Value {
+ names = append(names, p.Name)
+ }
+ }
+ return names
+}
diff --git a/internal/repository/pg_cliauth.go b/internal/repository/pg_cliauth.go
new file mode 100644
index 00000000..c6de0283
--- /dev/null
+++ b/internal/repository/pg_cliauth.go
@@ -0,0 +1,145 @@
+package repository
+
+import (
+ "context"
+ "errors"
+ "time"
+
+ "github.com/google/uuid"
+ "github.com/jackc/pgx/v5"
+ "github.com/jackc/pgx/v5/pgxpool"
+ "github.com/warmbly/warmbly/internal/infrastructure/db"
+ "github.com/warmbly/warmbly/internal/models"
+)
+
+// CLIAuthRepository stores the `warmbly auth login` handshake. Rows live for
+// minutes: the credential the flow produces is an ordinary API key.
+type CLIAuthRepository interface {
+ CreateCode(ctx context.Context, deviceCodeHash, userCode string, req models.CLIAuthStartRequest, expiresAt time.Time) (*models.CLIAuthCode, error)
+ GetCodeByUserCode(ctx context.Context, userCode string) (*models.CLIAuthCode, error)
+ // ApproveCode stores the minted secret for the next poll; false when the
+ // code is no longer pending, which is what makes approval single-use.
+ ApproveCode(ctx context.Context, userCode string, orgID, approvedBy, apiKeyID uuid.UUID, secret string) (bool, error)
+ DenyCode(ctx context.Context, userCode string) (bool, error)
+ // ClaimCode hands the secret out exactly once, clearing it in the same statement.
+ ClaimCode(ctx context.Context, deviceCodeHash string) (*models.CLIAuthCode, string, error)
+ DeleteExpiredCodes(ctx context.Context) error
+}
+
+type cliAuthRepository struct {
+ db *pgxpool.Pool
+}
+
+func NewCLIAuthRepository(db *pgxpool.Pool) CLIAuthRepository {
+ return &cliAuthRepository{db: db}
+}
+
+const cliAuthCodeColumns = `id, user_code, client_name, hostname, cli_version, scopes, status, organization_id, expires_at, created_at`
+
+func scanCLIAuthCode(row pgx.Row) (*models.CLIAuthCode, error) {
+ var c models.CLIAuthCode
+ var scopes int64
+ if err := row.Scan(&c.ID, &c.UserCode, &c.ClientName, &c.Hostname, &c.CLIVersion, &scopes, &c.Status, &c.OrganizationID, &c.ExpiresAt, &c.CreatedAt); err != nil {
+ if errors.Is(err, pgx.ErrNoRows) {
+ return nil, nil
+ }
+ return nil, err
+ }
+ c.Scopes = uint64(scopes)
+ c.ScopeNames = models.APIScopeNames(c.Scopes)
+ return &c, nil
+}
+
+func (r *cliAuthRepository) CreateCode(ctx context.Context, deviceCodeHash, userCode string, req models.CLIAuthStartRequest, expiresAt time.Time) (*models.CLIAuthCode, error) {
+ query := `
+ INSERT INTO cli_auth_codes (device_code_hash, user_code, client_name, hostname, cli_version, scopes, expires_at)
+ VALUES ($1, $2, $3, $4, $5, $6, $7)
+ RETURNING ` + cliAuthCodeColumns
+ c, err := scanCLIAuthCode(r.db.QueryRow(ctx, query, deviceCodeHash, userCode, req.ClientName, req.Hostname, req.CLIVersion, int64(req.Scopes), expiresAt))
+ if err != nil {
+ db.CaptureError(err, query, nil, "queryrow")
+ return nil, err
+ }
+ return c, nil
+}
+
+func (r *cliAuthRepository) GetCodeByUserCode(ctx context.Context, userCode string) (*models.CLIAuthCode, error) {
+ query := `SELECT ` + cliAuthCodeColumns + ` FROM cli_auth_codes WHERE user_code = $1 AND expires_at > NOW()`
+ c, err := scanCLIAuthCode(r.db.QueryRow(ctx, query, userCode))
+ if err != nil {
+ db.CaptureError(err, query, []any{userCode}, "queryrow")
+ return nil, err
+ }
+ return c, nil
+}
+
+func (r *cliAuthRepository) ApproveCode(ctx context.Context, userCode string, orgID, approvedBy, apiKeyID uuid.UUID, secret string) (bool, error) {
+ query := `
+ UPDATE cli_auth_codes
+ SET status = 'approved', organization_id = $2, approved_by = $3, api_key_id = $4, api_key_secret = $5
+ WHERE user_code = $1 AND status = 'pending' AND expires_at > NOW()
+ `
+ tag, err := r.db.Exec(ctx, query, userCode, orgID, approvedBy, apiKeyID, secret)
+ if err != nil {
+ db.CaptureError(err, query, nil, "exec")
+ return false, err
+ }
+ return tag.RowsAffected() == 1, nil
+}
+
+func (r *cliAuthRepository) DenyCode(ctx context.Context, userCode string) (bool, error) {
+ query := `UPDATE cli_auth_codes SET status = 'denied' WHERE user_code = $1 AND status = 'pending'`
+ tag, err := r.db.Exec(ctx, query, userCode)
+ if err != nil {
+ db.CaptureError(err, query, []any{userCode}, "exec")
+ return false, err
+ }
+ return tag.RowsAffected() == 1, nil
+}
+
+func (r *cliAuthRepository) ClaimCode(ctx context.Context, deviceCodeHash string) (*models.CLIAuthCode, string, error) {
+ // The secret comes from the locked pre-update row; RETURNING would only
+ // see the cleared value.
+ query := `
+ WITH picked AS (
+ SELECT id, api_key_secret
+ FROM cli_auth_codes
+ WHERE device_code_hash = $1 AND status = 'approved' AND expires_at > NOW()
+ FOR UPDATE
+ ), claimed AS (
+ UPDATE cli_auth_codes p
+ SET status = 'claimed', api_key_secret = NULL
+ FROM picked
+ WHERE p.id = picked.id
+ RETURNING p.id, p.user_code, p.client_name, p.hostname, p.cli_version, p.scopes, p.status, p.organization_id, p.expires_at, p.created_at, picked.api_key_secret AS secret
+ )
+ SELECT id, user_code, client_name, hostname, cli_version, scopes, status, organization_id, expires_at, created_at, COALESCE(secret, '') FROM claimed
+ UNION ALL
+ SELECT ` + cliAuthCodeColumns + `, '' FROM cli_auth_codes
+ WHERE device_code_hash = $1 AND expires_at > NOW() AND NOT EXISTS (SELECT 1 FROM claimed)
+ LIMIT 1
+ `
+ var c models.CLIAuthCode
+ var scopes int64
+ var secret string
+ err := r.db.QueryRow(ctx, query, deviceCodeHash).Scan(&c.ID, &c.UserCode, &c.ClientName, &c.Hostname, &c.CLIVersion, &scopes, &c.Status, &c.OrganizationID, &c.ExpiresAt, &c.CreatedAt, &secret)
+ if err != nil {
+ if errors.Is(err, pgx.ErrNoRows) {
+ return nil, "", nil
+ }
+ db.CaptureError(err, query, nil, "queryrow")
+ return nil, "", err
+ }
+ c.Scopes = uint64(scopes)
+ c.ScopeNames = models.APIScopeNames(c.Scopes)
+ // The claimed row reports its new status; the caller wants "approved".
+ if secret != "" {
+ c.Status = models.CLIAuthCodeApproved
+ }
+ return &c, secret, nil
+}
+
+func (r *cliAuthRepository) DeleteExpiredCodes(ctx context.Context) error {
+ _, err := r.db.Exec(ctx, `DELETE FROM cli_auth_codes WHERE expires_at < NOW() - INTERVAL '1 day'`)
+ return err
+}
diff --git a/web/src/app/cli/page.tsx b/web/src/app/cli/page.tsx
new file mode 100644
index 00000000..d5757d91
--- /dev/null
+++ b/web/src/app/cli/page.tsx
@@ -0,0 +1,500 @@
+// app.warmbly.com/cli?code=XXXX-XXXX — a signed-in member authorizes the
+// `warmbly` CLI running on one of their machines. Approving mints an ordinary
+// API key in the workspace they pick, which the CLI collects on its next poll.
+// Standalone on the auth screen's sky: enter code, review, done.
+
+import React from "react";
+import { Link, Navigate, useSearchParams } from "react-router-dom";
+import { AnimatePresence, motion } from "framer-motion";
+import { REGEXP_ONLY_DIGITS_AND_CHARS } from "input-otp";
+import toast from "react-hot-toast";
+import {
+ ArrowLeftIcon,
+ ArrowRightIcon,
+ BuildingIcon,
+ CheckIcon,
+ ExternalLinkIcon,
+ KeyRoundIcon,
+ Loader2Icon,
+ LockIcon,
+ MonitorIcon,
+ TerminalIcon,
+ XIcon,
+} from "lucide-react";
+import { Logo } from "@/components/svg";
+import { InputOTP, InputOTPGroup, InputOTPSlot } from "@/components/ui/input-otp";
+import { WEBSITE_URL } from "@/lib/information";
+import getToken from "@/lib/helper/getToken";
+import type { AppError } from "@/lib/api/client/normalizeError";
+import buildError from "@/lib/helper/buildError";
+import type Organization from "@/lib/api/models/app/organizations/Organization";
+import useOrganizations from "@/lib/api/hooks/app/organizations/useOrganizations";
+import type { CLIAuthCode } from "@/lib/api/models/app/cliauth/CLIAuth";
+import { useApproveCLIAuthCode, useCLIAuthCode, useDenyCLIAuthCode } from "@/lib/api/hooks/app/cliauth/useCLIAuth";
+
+const CODE_LENGTH = 8;
+
+function clean(raw: string): string {
+ return raw.toUpperCase().replace(/[^A-Z0-9]/g, "").slice(0, CODE_LENGTH);
+}
+
+function dashed(code: string): string {
+ return code.length > 4 ? `${code.slice(0, 4)}-${code.slice(4)}` : code;
+}
+
+const slide = {
+ enter: (dir: number) => ({ opacity: 0, x: dir > 0 ? 28 : -28 }),
+ center: { opacity: 1, x: 0 },
+ exit: (dir: number) => ({ opacity: 0, x: dir > 0 ? -28 : 28 }),
+};
+const slideTransition = { duration: 0.28, ease: [0.16, 1, 0.3, 1] as const };
+
+export default function CLIAuthPage() {
+ if (!getToken()) {
+ const next = encodeURIComponent(window.location.pathname + window.location.search);
+ return
+ Enter the eight character code your terminal is showing. Approving it creates an API key for that machine, which you can revoke here at any time. +
+{error.message || "That code is unknown or has expired."}
++ Run warmbly auth login again for a fresh one, then{" "} + + . +
+{info.client_name || "Warmbly CLI"}
+
+
+ {info.status === "denied" + ? "The request was declined. Run `warmbly auth login` again if you changed your mind." + : "It is signed in already. If the terminal is still waiting, run `warmbly auth login` again for a fresh code."} +
+Sign in to workspace
+You are not a member of any workspace yet.
} ++ This terminal will be able to +
++ These scopes include sending. A CLI signed in with them can start campaigns and send replies, which puts real mail on the wire. +
+ )} + ++ {approved ? ( + <> + {info.hostname || "Your terminal"} picks this up on its own within a few seconds + {orgName ? ( + <> + {" "} + and is now signed in to {orgName}. + > + ) : ( + "." + )}{" "} + You can close this tab. + > + ) : ( + "Nothing was created. The terminal will show that the request was declined." + )} +
+