From 028689fd2ea9e2b6d0fda4c60033e5c0e5d1cbd8 Mon Sep 17 00:00:00 2001 From: Matthew Meszaros Date: Fri, 4 Sep 2026 20:14:27 -0700 Subject: [PATCH 1/7] feat: device-code sign-in for the CLI, with a browser approval page at /cli that mints a scoped API key, self-revocation at DELETE /api-keys/self so a read-only credential can always end itself, and app_url plus websocket_url on /auth/config so a client can find the dashboard and the realtime gateway on a self-hosted layout --- cmd/backend/main.go | 6 + internal/api/handler/api_key.go | 31 ++ internal/api/handler/auth_config.go | 14 + internal/api/handler/cli_auth.go | 126 +++++ internal/api/handler/handler.go | 4 + internal/api/routes.go | 28 + internal/app/cliauth/service.go | 286 ++++++++++ internal/app/orgtransfer/spec.go | 1 + internal/config/constants.go | 5 + internal/config/endpoints.go | 17 + .../db/migrations/000128_cli_auth.down.sql | 1 + .../db/migrations/000128_cli_auth.up.sql | 28 + internal/models/cli_auth.go | 91 ++++ internal/repository/pg_cliauth.go | 145 +++++ web/src/app/cli/page.tsx | 500 ++++++++++++++++++ web/src/hooks/useDocumentTitle.ts | 1 + web/src/lib/api/client/app/cliauth/cliAuth.ts | 22 + .../lib/api/hooks/app/cliauth/useCLIAuth.ts | 28 + web/src/lib/api/models/app/cliauth/CLIAuth.ts | 18 + web/src/main.tsx | 6 + 20 files changed, 1358 insertions(+) create mode 100644 internal/api/handler/cli_auth.go create mode 100644 internal/app/cliauth/service.go create mode 100644 internal/infrastructure/db/migrations/000128_cli_auth.down.sql create mode 100644 internal/infrastructure/db/migrations/000128_cli_auth.up.sql create mode 100644 internal/models/cli_auth.go create mode 100644 internal/repository/pg_cliauth.go create mode 100644 web/src/app/cli/page.tsx create mode 100644 web/src/lib/api/client/app/cliauth/cliAuth.ts create mode 100644 web/src/lib/api/hooks/app/cliauth/useCLIAuth.ts create mode 100644 web/src/lib/api/models/app/cliauth/CLIAuth.ts 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 ; + } + return ; +} + +function CLIAuthInner() { + const [params] = useSearchParams(); + const [code, setCode] = React.useState(() => clean(params.get("code") ?? "")); + const complete = code.length === CODE_LENGTH; + const lookup = useCLIAuthCode(dashed(code)); + const orgs = useOrganizations(); + const approve = useApproveCLIAuthCode(); + const deny = useDenyCLIAuthCode(); + const [orgId, setOrgId] = React.useState(""); + const [outcome, setOutcome] = React.useState<"approved" | "denied" | null>(null); + const [dir, setDir] = React.useState(1); + + React.useEffect(() => { + if (!orgId && orgs.data && orgs.data.length > 0) setOrgId(orgs.data[0].id); + }, [orgs.data, orgId]); + + const info = complete ? lookup.data : undefined; + const step: "code" | "review" | "done" = outcome ? "done" : info ? "review" : "code"; + + const reset = () => { + setDir(-1); + setCode(""); + setOutcome(null); + }; + + const doApprove = async () => { + try { + setDir(1); + await approve.mutateAsync({ code: dashed(code), organizationId: orgId }); + setOutcome("approved"); + } catch (e) { + toast.error(buildError(e as AppError)); + } + }; + const doDeny = async () => { + try { + setDir(1); + await deny.mutateAsync(dashed(code)); + setOutcome("denied"); + } catch (e) { + toast.error(buildError(e as AppError)); + } + }; + + return ( +
+