Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
7d2adb5
Add auth/ library: device flow + tokens + token storage
khaong May 6, 2026
cd0648a
Wire cmd/entire/cli/auth shims to use the shared auth/ library
khaong May 6, 2026
a94bf89
Wire ENTIRE_AUTH_PROVIDER_VERSION switch to select v1 or v2
khaong May 6, 2026
6624e25
Add auth/sts: RFC 8693 OAuth 2.0 Token Exchange client
khaong May 6, 2026
9e9bc3f
auth: surface friendly error when OAuth response is HTML, not JSON
khaong May 7, 2026
0f05c15
auth/deviceflow: surface error_description from RFC 8628 §3.5 errors
khaong May 7, 2026
bb13cbd
api/auth_tokens: route to /api/v1/auth/tokens or /api/auth/tokens by …
khaong May 7, 2026
d5737db
auth: clear lint findings (errcheck, gosec G101/G117, unparam, goconst)
khaong May 7, 2026
c492a54
auth: split-host config + RFC 8693 token exchange (auth/tokenmanager)
khaong May 7, 2026
d9322bc
auth: route STS to provider.stsPath; make STSPath optional in tokenma…
khaong May 8, 2026
ead027c
search: route bearer through auth.TokenForResource
khaong May 8, 2026
16746fd
auth: fix legacy keyring fallback + cover gaps surfaced by review
khaong May 8, 2026
5173d30
auth: round-2 review fixes (DeleteCoreToken order, coverage, deprecat…
khaong May 8, 2026
f33b79d
Fix token exchange resource routing
khaong May 8, 2026
d8ccd26
Make auth tests independent of provider env
khaong May 8, 2026
a9aeb9e
dispatch: route bearer through tokenmanager + document the auth pattern
khaong May 8, 2026
b410d50
auth: PR review fixes (PollDeviceAuth retry, doc accuracy)
khaong May 8, 2026
9c2b070
auth: PR review fixes (parallel-safe clock pin, struct cache key)
khaong May 8, 2026
6a9e601
auth: review follow-ups (provider routing, URL normalization, expiry …
Soph May 8, 2026
dc7c003
Merge pull request #1156 from entireio/alex/cli-auth-followup-fixes
khaong May 14, 2026
60d6357
Merge remote-tracking branch 'origin/main' into alex/cli-auth-consoli…
khaong May 14, 2026
706a4a2
auth: defense-in-depth security hardening
khaong May 14, 2026
8821a55
auth: extract auth/ subtree into github.com/entireio/auth-go
khaong May 14, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -340,6 +340,32 @@ if settings.IsSummarizeEnabled() {
- `settings/settings.go` - `EntireSettings` struct, `Load()`, and helper methods
- `config.go` - Higher-level config functions that use settings (for `cli` package consumers)

### Auth and token resolution

The CLI uses a shareable auth library at `auth/` (subpackages: `deviceflow`, `sts`, `tokens`, `tokenstore`, `tokenmanager`). The `cmd/entire/cli/auth/` package wraps it with entire-specific config (provider table, keyring service name) and exposes the call surface that command code should use.

**For every data-API call, get the bearer through one of these two entry points — never read the keyring directly:**

```go
// Preferred — for callers that need an *api.Client.
client, err := cli.NewAuthenticatedAPIClient(ctx, insecureHTTP)

// Direct — for callers that hand the bearer to a non-api package
// (e.g. the search service client, dispatch CloudClient).
bearer, err := auth.TokenForResource(ctx, serviceURL)
```

Both route through `tokenmanager.Manager.Token`, which:

1. Returns `auth.ErrNotLoggedIn` when the keyring is empty.
2. Hits the same-host shortcut when `api.AuthBaseURL() == resourceURL`.
3. Hits the JWT-`aud`-includes-resource shortcut when the core token is already valid for `resourceURL` (caller didn't request an explicit `Audience`).
4. Otherwise runs an RFC 8693 token exchange against the auth host's STS endpoint and caches the result per `(core token, resource, audience, requested-token-type, scope)`.

**Don't use `auth.LookupCurrentToken` for data-API calls.** It returns the raw core token (audience = auth host). On split-host deployments (`ENTIRE_AUTH_BASE_URL` set) the data API will reject it with 401. `LookupCurrentToken` is correct only for auth-host-targeted commands (`auth list/revoke/status`, `logout`) — they intentionally hold the auth-audience bearer.

**Test injection:** at the cmd layer use `auth.SetManagerForTest(t, mgr)` with a `tokenmanager.Manager` constructed via `tokenmanager.New(Config{Exchange: ...})`. The manager's `Config.Exchange` and `Config.Now` fields are test seams — production callers leave them nil.

### Logging vs User Output

- **Internal/debug logging**: Use `logging.Debug/Info/Warn/Error(ctx, msg, attrs...)` from `cmd/entire/cli/logging/`. Writes to `.entire/logs/`.
Expand Down
2 changes: 1 addition & 1 deletion cmd/entire/cli/activity_cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ func newActivityCmd() *cobra.Command {
}

func runActivity(ctx context.Context, w, errW io.Writer) error {
client, err := NewAuthenticatedAPIClient(false)
client, err := NewAuthenticatedAPIClient(ctx, false)
if err != nil {
fmt.Fprintln(errW, "Not logged in. Run 'entire login' to authenticate.")
return NewSilentError(err)
Expand Down
40 changes: 32 additions & 8 deletions cmd/entire/cli/api/auth_tokens.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,12 @@ package api

import (
"context"
"errors"
"fmt"
"net/url"
)

// Token is a single API token row returned by GET /api/v1/auth/tokens.
// Token is a single API token row returned by the auth-tokens endpoint.
// Plaintext token values are never returned by the server — only metadata.
type Token struct {
ID string `json:"id"`
Expand All @@ -18,15 +19,32 @@ type Token struct {
CreatedAt string `json:"created_at"`
}

// TokensResponse is the envelope returned by GET /api/v1/auth/tokens.
// TokensResponse is the envelope returned by the list endpoint.
type TokensResponse struct {
Tokens []Token `json:"tokens"`
}

// errAuthTokensPathUnset surfaces when an auth-tokens method is called
// on a Client that wasn't given a base path. Construct via
// NewClientWithBaseURL(...).WithAuthTokensPath(...) — the active path
// lives in cmd/entire/cli/auth.CurrentProvider().AuthTokensPath, the
// single source of truth for provider-version routing.
var errAuthTokensPathUnset = errors.New("api: auth-tokens path is unset (call (*Client).WithAuthTokensPath before list/revoke)")

func (c *Client) authTokensBasePath() (string, error) {
if c.authTokensPath == "" {
return "", errAuthTokensPathUnset
}
return c.authTokensPath, nil
}

// ListTokens returns the authenticated user's non-expired API tokens.
// Backed by GET /api/v1/auth/tokens.
func (c *Client) ListTokens(ctx context.Context) ([]Token, error) {
resp, err := c.Get(ctx, "/api/v1/auth/tokens")
base, err := c.authTokensBasePath()
if err != nil {
return nil, fmt.Errorf("list tokens: %w", err)
}
resp, err := c.Get(ctx, base)
if err != nil {
return nil, fmt.Errorf("list tokens: %w", err)
}
Expand All @@ -44,9 +62,12 @@ func (c *Client) ListTokens(ctx context.Context) ([]Token, error) {
}

// RevokeCurrentToken revokes the bearer token used to authenticate this client.
// Backed by DELETE /api/v1/auth/tokens/current.
func (c *Client) RevokeCurrentToken(ctx context.Context) error {
resp, err := c.Delete(ctx, "/api/v1/auth/tokens/current")
base, err := c.authTokensBasePath()
if err != nil {
return fmt.Errorf("revoke current token: %w", err)
}
resp, err := c.Delete(ctx, base+"/current")
if err != nil {
Comment thread
khaong marked this conversation as resolved.
return fmt.Errorf("revoke current token: %w", err)
}
Expand All @@ -59,9 +80,12 @@ func (c *Client) RevokeCurrentToken(ctx context.Context) error {
}

// RevokeToken revokes the API token with the given id.
// Backed by DELETE /api/v1/auth/tokens/{id}.
func (c *Client) RevokeToken(ctx context.Context, id string) error {
resp, err := c.Delete(ctx, "/api/v1/auth/tokens/"+url.PathEscape(id))
base, err := c.authTokensBasePath()
if err != nil {
return fmt.Errorf("revoke token %s: %w", id, err)
}
resp, err := c.Delete(ctx, base+"/"+url.PathEscape(id))
if err != nil {
return fmt.Errorf("revoke token %s: %w", id, err)
}
Expand Down
77 changes: 65 additions & 12 deletions cmd/entire/cli/api/auth_tokens_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,21 @@ import (
"testing"
)

const (
testV1AuthTokensPath = "/api/v1/auth/tokens"
testV2AuthTokensPath = "/api/auth/tokens"
)

// newAuthTokensTestClient builds a Client pointed at server.URL with
// the given auth-tokens base path. Used by all auth-tokens tests so
// the wiring matches production: callers chain WithAuthTokensPath at
// construction time.
func newAuthTokensTestClient(serverURL, authTokensPath string) *Client {
c := NewClient("tok").WithAuthTokensPath(authTokensPath)
c.baseURL = serverURL
return c
}

func TestClient_RevokeCurrentToken_SendsDeleteWithBearer(t *testing.T) {
t.Parallel()

Expand All @@ -23,8 +38,7 @@ func TestClient_RevokeCurrentToken_SendsDeleteWithBearer(t *testing.T) {
}))
defer server.Close()

c := NewClient("tok")
c.baseURL = server.URL
c := newAuthTokensTestClient(server.URL, testV1AuthTokensPath)

if err := c.RevokeCurrentToken(context.Background()); err != nil {
t.Fatalf("RevokeCurrentToken() error = %v", err)
Expand All @@ -51,8 +65,7 @@ func TestClient_RevokeCurrentToken_ReturnsHTTPErrorOn401(t *testing.T) {
}))
defer server.Close()

c := NewClient("tok")
c.baseURL = server.URL
c := newAuthTokensTestClient(server.URL, testV1AuthTokensPath)

err := c.RevokeCurrentToken(context.Background())
if err == nil {
Expand Down Expand Up @@ -87,8 +100,7 @@ func TestClient_ListTokens_DecodesResponse(t *testing.T) {
}))
defer server.Close()

c := NewClient("tok")
c.baseURL = server.URL
c := newAuthTokensTestClient(server.URL, testV1AuthTokensPath)

tokens, err := c.ListTokens(context.Background())
if err != nil {
Expand Down Expand Up @@ -129,8 +141,7 @@ func TestClient_ListTokens_ReturnsHTTPErrorOn401(t *testing.T) {
}))
defer server.Close()

c := NewClient("tok")
c.baseURL = server.URL
c := newAuthTokensTestClient(server.URL, testV1AuthTokensPath)

_, err := c.ListTokens(context.Background())
if err == nil {
Expand All @@ -155,8 +166,7 @@ func TestClient_RevokeToken_SendsDeleteWithEscapedID(t *testing.T) {
}))
defer server.Close()

c := NewClient("tok")
c.baseURL = server.URL
c := newAuthTokensTestClient(server.URL, testV1AuthTokensPath)

// Use an id that needs URL escaping to verify we don't blindly concat.
if err := c.RevokeToken(context.Background(), "abc/def 1"); err != nil {
Expand Down Expand Up @@ -184,8 +194,7 @@ func TestClient_RevokeToken_ReturnsErrorBody(t *testing.T) {
}))
defer server.Close()

c := NewClient("tok")
c.baseURL = server.URL
c := newAuthTokensTestClient(server.URL, testV1AuthTokensPath)

err := c.RevokeToken(context.Background(), "missing")
if err == nil {
Expand All @@ -198,3 +207,47 @@ func TestClient_RevokeToken_ReturnsErrorBody(t *testing.T) {
t.Errorf("IsHTTPErrorStatus(err, 404) = false; err = %v", err)
}
}

// TestClient_AuthTokens_RoutesV2Path verifies that whatever path the
// caller supplies via WithAuthTokensPath is what hits the wire. The
// provider table itself (which path corresponds to which version) is
// exercised by cmd/entire/cli/auth's resolveProvider tests.
func TestClient_AuthTokens_RoutesV2Path(t *testing.T) {
t.Parallel()

var gotPath string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotPath = r.URL.Path
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"tokens":[]}`)) //nolint:errcheck // test handler
}))
defer server.Close()

c := newAuthTokensTestClient(server.URL, testV2AuthTokensPath)

if _, err := c.ListTokens(context.Background()); err != nil {
t.Fatalf("ListTokens: %v", err)
}
if gotPath != "/api/auth/tokens" {
t.Fatalf("path = %q, want /api/auth/tokens (v2)", gotPath)
}
}

// TestClient_AuthTokens_UnsetPathErrors guards against silently
// shipping a request to "" — we want a clear error pointing at the
// missing WithAuthTokensPath wiring.
func TestClient_AuthTokens_UnsetPathErrors(t *testing.T) {
t.Parallel()

c := NewClient("tok") // no WithAuthTokensPath

if _, err := c.ListTokens(context.Background()); !errors.Is(err, errAuthTokensPathUnset) {
t.Errorf("ListTokens err = %v, want errAuthTokensPathUnset", err)
}
if err := c.RevokeCurrentToken(context.Background()); !errors.Is(err, errAuthTokensPathUnset) {
t.Errorf("RevokeCurrentToken err = %v, want errAuthTokensPathUnset", err)
}
if err := c.RevokeToken(context.Background(), "any"); !errors.Is(err, errAuthTokensPathUnset) {
t.Errorf("RevokeToken err = %v, want errAuthTokensPathUnset", err)
}
}
19 changes: 19 additions & 0 deletions cmd/entire/cli/api/base_url.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,13 @@ const (

// BaseURLEnvVar overrides the Entire API origin for local development.
BaseURLEnvVar = "ENTIRE_API_BASE_URL"

// AuthBaseURLEnvVar overrides only the auth/login origin (device flow,
// auth-tokens management, keyring key). Falls back to BaseURLEnvVar when
// unset, which is the right behavior for single-host deployments. Split
// hosts (e.g. auth on us.console.partial.to, data on partial.to) set
// both.
AuthBaseURLEnvVar = "ENTIRE_AUTH_BASE_URL"
)

// BaseURL returns the effective Entire API base URL.
Expand All @@ -29,6 +36,18 @@ func BaseURL() string {
return DefaultBaseURL
}

// AuthBaseURL returns the origin used for the device-flow login, auth-token
// management endpoints, and the keyring key under which the bearer token is
// stored. ENTIRE_AUTH_BASE_URL takes precedence; otherwise it falls back to
// BaseURL() so single-host deployments keep working unchanged.
func AuthBaseURL() string {
if raw := strings.TrimSpace(os.Getenv(AuthBaseURLEnvVar)); raw != "" {
return normalizeBaseURL(raw)
}

return BaseURL()
}

// ResolveURL joins an API-relative path against the effective base URL.
func ResolveURL(path string) (string, error) {
return ResolveURLFromBase(BaseURL(), path)
Expand Down
22 changes: 22 additions & 0 deletions cmd/entire/cli/api/base_url_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,28 @@ func TestRequireSecureURL_RejectsHTTP(t *testing.T) {
}
}

func TestAuthBaseURL_FallsBackToBaseURL(t *testing.T) {
t.Setenv(BaseURLEnvVar, "https://partial.to")
t.Setenv(AuthBaseURLEnvVar, "")

if got := AuthBaseURL(); got != "https://partial.to" {
t.Fatalf("AuthBaseURL() = %q, want fallback to BaseURL %q", got, "https://partial.to")
}
}

func TestAuthBaseURL_OverridesBaseURL(t *testing.T) {
t.Setenv(BaseURLEnvVar, "https://partial.to")
t.Setenv(AuthBaseURLEnvVar, " https://us.console.partial.to/ ")

if got := AuthBaseURL(); got != "https://us.console.partial.to" {
t.Fatalf("AuthBaseURL() = %q, want %q", got, "https://us.console.partial.to")
}

if got := BaseURL(); got != "https://partial.to" {
t.Fatalf("BaseURL() = %q, want unchanged %q", got, "https://partial.to")
}
}

func TestResolveURL(t *testing.T) {
t.Setenv(BaseURLEnvVar, "http://localhost:8787/")

Expand Down
43 changes: 40 additions & 3 deletions cmd/entire/cli/api/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,18 +21,47 @@ const (
type Client struct {
httpClient *http.Client
baseURL string
}

// NewClient creates a new authenticated API client with an explicit bearer token.
// authTokensPath is the base path for the auth-tokens management
// endpoints (list / revoke). Set via WithAuthTokensPath when the
// client targets the auth host. Empty for data-API-only clients;
// auth-tokens methods error out if called against an empty path.
authTokensPath string
}

// WithAuthTokensPath sets the base path used by ListTokens,
// RevokeCurrentToken, and RevokeToken. The path is supplied by the
// auth shim from auth.CurrentProvider().AuthTokensPath, which is the
// single source of truth for provider-version routing — the api
// package no longer reads ENTIRE_AUTH_PROVIDER_VERSION itself.
//
// Returns the receiver for chaining at construction:
//
// c := api.NewClientWithBaseURL(token, base).WithAuthTokensPath(p)
func (c *Client) WithAuthTokensPath(path string) *Client {
c.authTokensPath = path
return c
}

// NewClient creates a new authenticated API client with an explicit bearer
// token, targeting the data API base URL (BaseURL()).
func NewClient(token string) *Client {
return NewClientWithBaseURL(token, BaseURL())
}

// NewClientWithBaseURL creates a new authenticated API client targeting an
// explicit base URL. Use this for endpoints that live on the auth host (e.g.
// auth-token management) when ENTIRE_AUTH_BASE_URL splits the auth origin
// from the data API origin.
func NewClientWithBaseURL(token, baseURL string) *Client {
return &Client{
httpClient: &http.Client{
Transport: &bearerTransport{
token: token,
base: http.DefaultTransport,
},
},
baseURL: BaseURL(),
baseURL: baseURL,
}
}

Expand All @@ -42,7 +71,15 @@ type bearerTransport struct {
base http.RoundTripper
}

// errEmptyBearerToken surfaces at first request rather than at construction
// because NewClient* don't return errors. An empty bearer otherwise becomes
// "Authorization: Bearer " on the wire and produces a confusing 401.
var errEmptyBearerToken = errors.New("api: refusing to send request with empty bearer token (construct via NewAuthenticatedAPIClient)")

func (t *bearerTransport) RoundTrip(req *http.Request) (*http.Response, error) {
if t.token == "" {
return nil, errEmptyBearerToken
}
// Clone the request to avoid mutating the caller's request.
r := req.Clone(req.Context())
r.Header.Set("Authorization", "Bearer "+t.token)
Expand Down
Loading
Loading