Skip to content

Commit 7ef77ea

Browse files
authored
Merge pull request #1377 from entireio/cor-389-data-api-context-aware
data-api: follow auth context via /.well-known/entire-api.json
2 parents 9403248 + 2ec7df5 commit 7ef77ea

22 files changed

Lines changed: 1360 additions & 294 deletions

cmd/entire/cli/activity_cmd_test.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,10 @@ func TestRunActivity_SilencesContextCanceled(t *testing.T) {
3636
return nil, context.Canceled
3737
})
3838
t.Cleanup(auth.SetManagerForTest(t, mgr))
39+
// Force discovery-unavailable so ResolveDataAPIToken takes the static
40+
// fallback through the singleton test manager above, rather than making a
41+
// real network fetch to the configured data host.
42+
t.Cleanup(auth.SetResolveContextForAPIForTest(t, auth.DiscoveryUnavailableForTest))
3943

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

7179
var out, errOut bytes.Buffer
7280
err := runActivity(t.Context(), &out, &errOut)

cmd/entire/cli/api_client.go

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -38,10 +38,12 @@ func NewAuthenticatedAPIClient(ctx context.Context, insecureHTTP bool) (*api.Cli
3838
}
3939
}
4040

41-
// tokenmanager validates Resource as a strict origin URL; strip any path
42-
// the operator may have included in ENTIRE_API_BASE_URL before handing
43-
// it across the package boundary.
44-
token, err := auth.TokenForResource(ctx, api.OriginOnly(dataURL))
41+
// ResolveDataAPIToken discovers which login context the data host trusts
42+
// (via its /.well-known/entire-api.json) and exchanges that context's
43+
// token for the advertised audience, falling back to static resolution
44+
// when the host doesn't advertise discovery. It normalises dataURL to an
45+
// origin internally.
46+
token, err := auth.ResolveDataAPIToken(ctx, dataURL)
4547
if err != nil {
4648
if errors.Is(err, auth.ErrNotLoggedIn) {
4749
// Wrap the original err (not the sentinel) so any context

cmd/entire/cli/auth.go

Lines changed: 29 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -75,18 +75,6 @@ func newAuthSessionsClient(coreURL, token string) *api.Client {
7575
return api.NewClientWithBaseURL(token, coreURL).WithAuthSessionsPath(coreAuthSessionsPath)
7676
}
7777

78-
// resolveAuthHostToken returns a bearer scoped for the auth host (entire-core).
79-
// For the auth host's own origin the tokenmanager hits the same-host shortcut
80-
// and returns the stored login JWT unchanged — keeping the entire:session
81-
// scope that core's session endpoints (and /me) require, with no STS exchange.
82-
func resolveAuthHostToken(ctx context.Context) (string, error) {
83-
token, err := auth.TokenForResource(ctx, api.OriginOnly(api.AuthBaseURL()))
84-
if err != nil {
85-
return "", fmt.Errorf("resolve auth-host token: %w", err)
86-
}
87-
return token, nil
88-
}
89-
9078
// isKeychainTokenRejected reports whether err indicates the stored
9179
// keyring token can't authenticate against entire-core. Failure modes that
9280
// collapse into the single "the user must re-login" branch:
@@ -167,7 +155,7 @@ func newAuthStatusCmd() *cobra.Command {
167155
if err := requireSecureBaseURL(insecureHTTPAuth); err != nil {
168156
return err
169157
}
170-
target, err := resolveStatusTarget(auth.NewContextStore(), auth.Contexts, api.AuthBaseURL())
158+
target, err := resolveStatusTarget(cmd.Context(), auth.NewContextStore(), auth.Contexts, auth.RefreshedLoginToken, api.AuthBaseURL())
171159
if err != nil {
172160
return err
173161
}
@@ -208,6 +196,11 @@ type authSessionLister func(ctx context.Context, coreURL, token string) ([]api.A
208196
// name. Injected for testability; production wires auth.Contexts.
209197
type contextsProvider func() ([]*contexts.Context, string, error)
210198

199+
// loginTokenResolver returns a usable login JWT for a context, transparently
200+
// re-minting an expired one from the stored refresh token. Injected so status
201+
// tests don't reach the network; production wires auth.RefreshedLoginToken.
202+
type loginTokenResolver func(ctx context.Context, c *contexts.Context) (string, error)
203+
211204
// statusTarget is the resolved core to act against: the active context's
212205
// CoreURL + its session token, or (no active context) the configured
213206
// AuthBaseURL + legacy keyring entry. Shared by `auth status` (profile +
@@ -219,17 +212,29 @@ type statusTarget struct {
219212
totalContexts int
220213
}
221214

222-
// resolveStatusTarget picks the core + token for `entire auth status`. The
223-
// active contexts.json context wins (so `auth use` retargets status onto that
224-
// login server); otherwise it falls back to the legacy keyring entry keyed by
225-
// the configured auth host.
215+
// resolveStatusTarget picks the core + token for `entire auth status` (and
216+
// `logout`). The active contexts.json context wins (so `auth use` retargets
217+
// status onto that login server); otherwise it falls back to the legacy keyring
218+
// entry keyed by the configured auth host.
219+
//
220+
// For the active context the token is resolved through resolveLogin, which
221+
// transparently re-mints an expired login JWT from the stored refresh token.
222+
// This is the point of the refresh: an expired-but-refreshable session must
223+
// report "logged in", not "re-login" — the same false negative
224+
// auth.ResolveControlPlaneTarget already avoids for org/repo/project/grant.
225+
// `logout` benefits too: the refreshed bearer can authenticate the revoke call
226+
// instead of failing on an expired token. When refresh fails (revoked family,
227+
// network, opaque token), we fall back to the stored token and let the /me
228+
// liveness probe be the arbiter — preserving the accurate "no longer valid"
229+
// outcome for a genuinely dead session (ErrReauthRequired → expired token →
230+
// 401 → re-login).
226231
//
227232
// A genuine contexts.json read/parse error is surfaced, not swallowed — a
228233
// missing file reads as "no contexts" (no error), so an error here means the
229234
// file is corrupt or unreadable, which the user must see. This keeps status
230235
// symmetric with the control-plane commands (auth.ResolveControlPlaneTarget),
231236
// which fail the same way rather than silently degrading to a stale identity.
232-
func resolveStatusTarget(store tokenStore, listContexts contextsProvider, fallbackBaseURL string) (statusTarget, error) {
237+
func resolveStatusTarget(ctx context.Context, store tokenStore, listContexts contextsProvider, resolveLogin loginTokenResolver, fallbackBaseURL string) (statusTarget, error) {
233238
all, current, err := listContexts()
234239
if err != nil {
235240
return statusTarget{}, fmt.Errorf("load contexts: %w", err)
@@ -239,6 +244,12 @@ func resolveStatusTarget(store tokenStore, listContexts contextsProvider, fallba
239244
if c.Name != current || c.CoreURL == "" {
240245
continue
241246
}
247+
// Prefer a refreshed token; fall back to the raw stored token so a
248+
// refresh failure degrades to today's behaviour rather than dropping
249+
// to the legacy entry.
250+
if tok, terr := resolveLogin(ctx, c); terr == nil && tok != "" {
251+
return statusTarget{coreURL: c.CoreURL, token: tok, activeContext: c.Name, totalContexts: total}, nil
252+
}
242253
if tok, terr := auth.LoginTokenForContext(c); terr == nil && tok != "" {
243254
return statusTarget{coreURL: c.CoreURL, token: tok, activeContext: c.Name, totalContexts: total}, nil
244255
}

cmd/entire/cli/auth/data_api.go

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
package auth
2+
3+
import (
4+
"context"
5+
"errors"
6+
"net/http"
7+
"net/url"
8+
"time"
9+
10+
"github.com/entireio/cli/cmd/entire/cli/api"
11+
"github.com/entireio/cli/internal/entireclient/clusterdiscovery"
12+
"github.com/entireio/cli/internal/entireclient/contexts"
13+
"github.com/entireio/cli/internal/entireclient/discovery"
14+
)
15+
16+
// dataAPIDiscoveryTimeout bounds the one /.well-known/entire-api.json GET we
17+
// add per data-API command. Kept short: on any failure we fall back to static
18+
// resolution, so a slow or absent endpoint must not stall the command.
19+
const dataAPIDiscoveryTimeout = 8 * time.Second
20+
21+
// resolveContextForAPIFunc is the shape of the discovery seam: it mirrors
22+
// clusterdiscovery.ResolveContextForAPI (ctx, configDir, cacheDir, apiHost,
23+
// httpClient, debugf).
24+
type resolveContextForAPIFunc func(context.Context, string, string, string, *http.Client, clusterdiscovery.DebugFunc) (*contexts.Context, error)
25+
26+
// resolveContextForAPI is the discovery seam, swapped in tests so they don't
27+
// reach the network. See SetResolveContextForAPIForTest for cross-package tests.
28+
var resolveContextForAPI resolveContextForAPIFunc = clusterdiscovery.ResolveContextForAPI
29+
30+
// SetResolveContextForAPIForTest overrides the /.well-known/entire-api.json
31+
// discovery seam and returns a cleanup func. Tests in other packages that
32+
// exercise a data-API command (activity/search/dispatch/recap) MUST install
33+
// this — otherwise ResolveDataAPIToken makes a real network call to the
34+
// configured data host and bypasses any SetManagerForTest fallback seam. Pass
35+
// a func returning clusterdiscovery.ErrDiscoveryUnavailable to force the static
36+
// fallback path. Test-only.
37+
func SetResolveContextForAPIForTest(t interface{ Helper() }, fn resolveContextForAPIFunc) func() {
38+
t.Helper()
39+
prev := resolveContextForAPI
40+
resolveContextForAPI = fn
41+
return func() { resolveContextForAPI = prev }
42+
}
43+
44+
// DiscoveryUnavailableForTest is a ready-made SetResolveContextForAPIForTest
45+
// value that forces the discovery-unavailable fallback (no network), so a
46+
// cross-package test exercises the static TokenForResource path deterministically.
47+
func DiscoveryUnavailableForTest(context.Context, string, string, string, *http.Client, clusterdiscovery.DebugFunc) (*contexts.Context, error) {
48+
return nil, clusterdiscovery.ErrDiscoveryUnavailable
49+
}
50+
51+
// ResolveDataAPIToken returns a bearer for the data API at dataBaseURL.
52+
//
53+
// It dials the API's /.well-known/entire-api.json to learn which login
54+
// server(s) the API trusts and which audience to exchange for, picks the
55+
// matching local auth context (active-wins-if-eligible → sole → explicit
56+
// choice), and exchanges that context's login JWT for the advertised audience
57+
// at that context's core. This is what makes
58+
//
59+
// ENTIRE_API_BASE_URL=https://partial.to entire activity
60+
//
61+
// authenticate as the partial.to login even while the active context is a
62+
// prod entire.io login — without the operator also setting ENTIRE_AUTH_BASE_URL.
63+
//
64+
// When the API doesn't advertise discovery (404 / unreachable / 503 /
65+
// malformed — e.g. a deployment predating the well-known), it falls back to
66+
// the pre-discovery static path (TokenForResource through the singleton
67+
// manager) so behaviour is never worse than before. A reachable API whose
68+
// context selection fails (no eligible context, or several with none active)
69+
// surfaces that error directly — the user must log in or pick one.
70+
//
71+
// Callers that honour --insecure-http-auth must call EnableInsecureHTTP before
72+
// invoking this (as they already do); the per-context exchange and the static
73+
// fallback both read that global opt-in.
74+
func ResolveDataAPIToken(ctx context.Context, dataBaseURL string) (string, error) {
75+
dataOrigin := api.OriginOnly(dataBaseURL)
76+
host, ok := hostOf(dataOrigin)
77+
if !ok {
78+
// Can't derive a host to discover against — use static resolution.
79+
return TokenForResource(ctx, dataOrigin)
80+
}
81+
82+
// Bridge any pre-contexts.json login so the resolver can match it, mirroring
83+
// the git remote helper's cold-boot path. Best-effort: a migration failure
84+
// must not block resolution.
85+
_, _ = MigrateLegacyLoginContext() //nolint:errcheck // best-effort bridge; resolution proceeds regardless
86+
87+
dctx, cancel := context.WithTimeout(ctx, dataAPIDiscoveryTimeout)
88+
defer cancel()
89+
httpClient := &http.Client{Timeout: dataAPIDiscoveryTimeout}
90+
91+
selected, err := resolveContextForAPI(dctx, contexts.DefaultConfigDir(), discovery.DefaultCacheDir(), host, httpClient, nil)
92+
if errors.Is(err, clusterdiscovery.ErrDiscoveryUnavailable) {
93+
// Old deployment / not rolled out / transient — preserve today's behaviour.
94+
return TokenForResource(ctx, dataOrigin)
95+
}
96+
if err != nil {
97+
return "", err
98+
}
99+
100+
// Exchange for the data host origin; the token manager derives the RFC 8693
101+
// audience from it, which is the aud the API requires (aud == base URI).
102+
allowInsecure := insecureHTTPEnabled() || isLoopbackHTTP(selected.CoreURL)
103+
provider, err := NewRefreshingResourceProvider(selected, dataOrigin, nil, allowInsecure)
104+
if err != nil {
105+
return "", err
106+
}
107+
return provider(ctx)
108+
}
109+
110+
// hostOf returns the host[:port] of an origin URL, ok=false when it can't be
111+
// parsed into a host.
112+
func hostOf(origin string) (string, bool) {
113+
u, err := url.Parse(origin)
114+
if err != nil || u.Host == "" {
115+
return "", false
116+
}
117+
return u.Host, true
118+
}

0 commit comments

Comments
 (0)