Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
8 changes: 8 additions & 0 deletions cmd/entire/cli/activity_cmd_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,10 @@ func TestRunActivity_SilencesContextCanceled(t *testing.T) {
return nil, context.Canceled
})
t.Cleanup(auth.SetManagerForTest(t, mgr))
// Force discovery-unavailable so ResolveDataAPIToken takes the static
// fallback through the singleton test manager above, rather than making a
// real network fetch to the configured data host.
t.Cleanup(auth.SetResolveContextForAPIForTest(t, auth.DiscoveryUnavailableForTest))

var out, errOut bytes.Buffer
err := runActivity(t.Context(), &out, &errOut)
Expand Down Expand Up @@ -67,6 +71,10 @@ func TestRunActivity_PrintsLoginHintOnNotLoggedIn(t *testing.T) {
return nil, errors.New("unreachable")
})
t.Cleanup(auth.SetManagerForTest(t, mgr))
// Force discovery-unavailable so ResolveDataAPIToken takes the static
// fallback through the singleton test manager above, rather than making a
// real network fetch to the configured data host.
t.Cleanup(auth.SetResolveContextForAPIForTest(t, auth.DiscoveryUnavailableForTest))

var out, errOut bytes.Buffer
err := runActivity(t.Context(), &out, &errOut)
Expand Down
10 changes: 6 additions & 4 deletions cmd/entire/cli/api_client.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,10 +38,12 @@ func NewAuthenticatedAPIClient(ctx context.Context, insecureHTTP bool) (*api.Cli
}
}

// tokenmanager validates Resource as a strict origin URL; strip any path
// the operator may have included in ENTIRE_API_BASE_URL before handing
// it across the package boundary.
token, err := auth.TokenForResource(ctx, api.OriginOnly(dataURL))
// ResolveDataAPIToken discovers which login context the data host trusts
// (via its /.well-known/entire-api.json) and exchanges that context's
// token for the advertised audience, falling back to static resolution
// when the host doesn't advertise discovery. It normalises dataURL to an
// origin internally.
token, err := auth.ResolveDataAPIToken(ctx, dataURL)
if err != nil {
if errors.Is(err, auth.ErrNotLoggedIn) {
// Wrap the original err (not the sentinel) so any context
Expand Down
47 changes: 29 additions & 18 deletions cmd/entire/cli/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -75,18 +75,6 @@ func newAuthSessionsClient(coreURL, token string) *api.Client {
return api.NewClientWithBaseURL(token, coreURL).WithAuthSessionsPath(coreAuthSessionsPath)
}

// resolveAuthHostToken returns a bearer scoped for the auth host (entire-core).
// For the auth host's own origin the tokenmanager hits the same-host shortcut
// and returns the stored login JWT unchanged — keeping the entire:session
// scope that core's session endpoints (and /me) require, with no STS exchange.
func resolveAuthHostToken(ctx context.Context) (string, error) {
token, err := auth.TokenForResource(ctx, api.OriginOnly(api.AuthBaseURL()))
if err != nil {
return "", fmt.Errorf("resolve auth-host token: %w", err)
}
return token, nil
}

// isKeychainTokenRejected reports whether err indicates the stored
// keyring token can't authenticate against entire-core. Failure modes that
// collapse into the single "the user must re-login" branch:
Expand Down Expand Up @@ -167,7 +155,7 @@ func newAuthStatusCmd() *cobra.Command {
if err := requireSecureBaseURL(insecureHTTPAuth); err != nil {
return err
}
target, err := resolveStatusTarget(auth.NewContextStore(), auth.Contexts, api.AuthBaseURL())
target, err := resolveStatusTarget(cmd.Context(), auth.NewContextStore(), auth.Contexts, auth.RefreshedLoginToken, api.AuthBaseURL())
if err != nil {
return err
}
Expand Down Expand Up @@ -208,6 +196,11 @@ type authSessionLister func(ctx context.Context, coreURL, token string) ([]api.A
// name. Injected for testability; production wires auth.Contexts.
type contextsProvider func() ([]*contexts.Context, string, error)

// loginTokenResolver returns a usable login JWT for a context, transparently
// re-minting an expired one from the stored refresh token. Injected so status
// tests don't reach the network; production wires auth.RefreshedLoginToken.
type loginTokenResolver func(ctx context.Context, c *contexts.Context) (string, error)

// statusTarget is the resolved core to act against: the active context's
// CoreURL + its session token, or (no active context) the configured
// AuthBaseURL + legacy keyring entry. Shared by `auth status` (profile +
Expand All @@ -219,17 +212,29 @@ type statusTarget struct {
totalContexts int
}

// resolveStatusTarget picks the core + token for `entire auth status`. The
// active contexts.json context wins (so `auth use` retargets status onto that
// login server); otherwise it falls back to the legacy keyring entry keyed by
// the configured auth host.
// resolveStatusTarget picks the core + token for `entire auth status` (and
// `logout`). The active contexts.json context wins (so `auth use` retargets
// status onto that login server); otherwise it falls back to the legacy keyring
// entry keyed by the configured auth host.
//
// For the active context the token is resolved through resolveLogin, which
// transparently re-mints an expired login JWT from the stored refresh token.
// This is the point of the refresh: an expired-but-refreshable session must
// report "logged in", not "re-login" — the same false negative
// auth.ResolveControlPlaneTarget already avoids for org/repo/project/grant.
// `logout` benefits too: the refreshed bearer can authenticate the revoke call
// instead of failing on an expired token. When refresh fails (revoked family,
// network, opaque token), we fall back to the stored token and let the /me
// liveness probe be the arbiter — preserving the accurate "no longer valid"
// outcome for a genuinely dead session (ErrReauthRequired → expired token →
// 401 → re-login).
//
// A genuine contexts.json read/parse error is surfaced, not swallowed — a
// missing file reads as "no contexts" (no error), so an error here means the
// file is corrupt or unreadable, which the user must see. This keeps status
// symmetric with the control-plane commands (auth.ResolveControlPlaneTarget),
// which fail the same way rather than silently degrading to a stale identity.
func resolveStatusTarget(store tokenStore, listContexts contextsProvider, fallbackBaseURL string) (statusTarget, error) {
func resolveStatusTarget(ctx context.Context, store tokenStore, listContexts contextsProvider, resolveLogin loginTokenResolver, fallbackBaseURL string) (statusTarget, error) {
all, current, err := listContexts()
if err != nil {
return statusTarget{}, fmt.Errorf("load contexts: %w", err)
Expand All @@ -239,6 +244,12 @@ func resolveStatusTarget(store tokenStore, listContexts contextsProvider, fallba
if c.Name != current || c.CoreURL == "" {
continue
}
// Prefer a refreshed token; fall back to the raw stored token so a
// refresh failure degrades to today's behaviour rather than dropping
// to the legacy entry.
if tok, terr := resolveLogin(ctx, c); terr == nil && tok != "" {
return statusTarget{coreURL: c.CoreURL, token: tok, activeContext: c.Name, totalContexts: total}, nil
}
if tok, terr := auth.LoginTokenForContext(c); terr == nil && tok != "" {
return statusTarget{coreURL: c.CoreURL, token: tok, activeContext: c.Name, totalContexts: total}, nil
}
Expand Down
118 changes: 118 additions & 0 deletions cmd/entire/cli/auth/data_api.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
package auth

import (
"context"
"errors"
"net/http"
"net/url"
"time"

"github.com/entireio/cli/cmd/entire/cli/api"
"github.com/entireio/cli/internal/entireclient/clusterdiscovery"
"github.com/entireio/cli/internal/entireclient/contexts"
"github.com/entireio/cli/internal/entireclient/discovery"
)

// dataAPIDiscoveryTimeout bounds the one /.well-known/entire-api.json GET we
// add per data-API command. Kept short: on any failure we fall back to static
// resolution, so a slow or absent endpoint must not stall the command.
const dataAPIDiscoveryTimeout = 8 * time.Second

// resolveContextForAPIFunc is the shape of the discovery seam: it mirrors
// clusterdiscovery.ResolveContextForAPI (ctx, configDir, cacheDir, apiHost,
// httpClient, debugf).
type resolveContextForAPIFunc func(context.Context, string, string, string, *http.Client, clusterdiscovery.DebugFunc) (*contexts.Context, error)

// resolveContextForAPI is the discovery seam, swapped in tests so they don't
// reach the network. See SetResolveContextForAPIForTest for cross-package tests.
var resolveContextForAPI resolveContextForAPIFunc = clusterdiscovery.ResolveContextForAPI

// SetResolveContextForAPIForTest overrides the /.well-known/entire-api.json
// discovery seam and returns a cleanup func. Tests in other packages that
// exercise a data-API command (activity/search/dispatch/recap) MUST install
// this — otherwise ResolveDataAPIToken makes a real network call to the
// configured data host and bypasses any SetManagerForTest fallback seam. Pass
// a func returning clusterdiscovery.ErrDiscoveryUnavailable to force the static
// fallback path. Test-only.
func SetResolveContextForAPIForTest(t interface{ Helper() }, fn resolveContextForAPIFunc) func() {
t.Helper()
prev := resolveContextForAPI
resolveContextForAPI = fn
return func() { resolveContextForAPI = prev }
}

// DiscoveryUnavailableForTest is a ready-made SetResolveContextForAPIForTest
// value that forces the discovery-unavailable fallback (no network), so a
// cross-package test exercises the static TokenForResource path deterministically.
func DiscoveryUnavailableForTest(context.Context, string, string, string, *http.Client, clusterdiscovery.DebugFunc) (*contexts.Context, error) {
return nil, clusterdiscovery.ErrDiscoveryUnavailable
}

// ResolveDataAPIToken returns a bearer for the data API at dataBaseURL.
//
// It dials the API's /.well-known/entire-api.json to learn which login
// server(s) the API trusts and which audience to exchange for, picks the
// matching local auth context (active-wins-if-eligible → sole → explicit
// choice), and exchanges that context's login JWT for the advertised audience
// at that context's core. This is what makes
//
// ENTIRE_API_BASE_URL=https://partial.to entire activity
//
// authenticate as the partial.to login even while the active context is a
// prod entire.io login — without the operator also setting ENTIRE_AUTH_BASE_URL.
//
// When the API doesn't advertise discovery (404 / unreachable / 503 /
// malformed — e.g. a deployment predating the well-known), it falls back to
// the pre-discovery static path (TokenForResource through the singleton
// manager) so behaviour is never worse than before. A reachable API whose
// context selection fails (no eligible context, or several with none active)
// surfaces that error directly — the user must log in or pick one.
//
// Callers that honour --insecure-http-auth must call EnableInsecureHTTP before
// invoking this (as they already do); the per-context exchange and the static
// fallback both read that global opt-in.
func ResolveDataAPIToken(ctx context.Context, dataBaseURL string) (string, error) {
dataOrigin := api.OriginOnly(dataBaseURL)
host, ok := hostOf(dataOrigin)
if !ok {
// Can't derive a host to discover against — use static resolution.
return TokenForResource(ctx, dataOrigin)
}

// Bridge any pre-contexts.json login so the resolver can match it, mirroring
// the git remote helper's cold-boot path. Best-effort: a migration failure
// must not block resolution.
_, _ = MigrateLegacyLoginContext() //nolint:errcheck // best-effort bridge; resolution proceeds regardless

dctx, cancel := context.WithTimeout(ctx, dataAPIDiscoveryTimeout)
defer cancel()
httpClient := &http.Client{Timeout: dataAPIDiscoveryTimeout}

selected, err := resolveContextForAPI(dctx, contexts.DefaultConfigDir(), discovery.DefaultCacheDir(), host, httpClient, nil)
if errors.Is(err, clusterdiscovery.ErrDiscoveryUnavailable) {
// Old deployment / not rolled out / transient — preserve today's behaviour.
return TokenForResource(ctx, dataOrigin)
}
if err != nil {
return "", err
}

// Exchange for the data host origin; the token manager derives the RFC 8693
// audience from it, which is the aud the API requires (aud == base URI).
allowInsecure := insecureHTTPEnabled() || isLoopbackHTTP(selected.CoreURL)
provider, err := NewRefreshingResourceProvider(selected, dataOrigin, nil, allowInsecure)
if err != nil {
return "", err
}
return provider(ctx)
}

// hostOf returns the host[:port] of an origin URL, ok=false when it can't be
// parsed into a host.
func hostOf(origin string) (string, bool) {
u, err := url.Parse(origin)
if err != nil || u.Host == "" {
return "", false
}
return u.Host, true
}
Loading
Loading