Skip to content

Commit e88f171

Browse files
authored
Merge pull request #1321 from entireio/entire-token-env-override
git-remote-entire: ENTIRE_TOKEN env override for CI / workload identity
2 parents a089a74 + 3da5a75 commit e88f171

5 files changed

Lines changed: 511 additions & 18 deletions

File tree

cmd/entire/cli/auth/env_token.go

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
package auth
2+
3+
import (
4+
"fmt"
5+
"net/url"
6+
"strings"
7+
8+
"github.com/entireio/auth-go/tokens"
9+
)
10+
11+
// EnvTokenVar is the environment variable that, when set, bypasses
12+
// contexts.json and the keyring entirely: its value is used verbatim as the
13+
// login JWT for repo-scoped token exchange. This is the CI / workload-identity
14+
// path — a runner injects a short-lived login or sa-session JWT and clones
15+
// without an interactive `entire login`.
16+
const EnvTokenVar = "ENTIRE_TOKEN"
17+
18+
// CoreURLFromEnvToken derives the home-region core URL from an ENTIRE_TOKEN
19+
// JWT's audience claim. Login and sa-session JWTs carry aud=<home-region URL>,
20+
// which is what STS routing keys on — so we read aud, not iss (iss may be a
21+
// regional core that can't mint the cross-region exchange).
22+
//
23+
// SECURITY: the returned URL becomes the host the env token is POSTed to as a
24+
// subject_token during exchange. ParseClaims does NOT verify the signature, so
25+
// the audience is attacker-controlled if a forged token is injected. This
26+
// function only enforces the *shape* of a safe endpoint (https, bare origin);
27+
// the caller MUST additionally verify the URL is a trusted core for the target
28+
// cluster (see clusterdiscovery.ResolveClusterCores) before exchanging, or a
29+
// forged aud could redirect the token to an arbitrary host.
30+
//
31+
// Structural rules, all required:
32+
// - the aud is a well-formed absolute URL,
33+
// - scheme is https (no cleartext token exchange),
34+
// - it carries a host and no userinfo, path, query, or fragment — entire
35+
// cores are bare origins (https://core.example.com), so anything richer is
36+
// either a misconfigured token or an attempt to smuggle a path/redirect.
37+
//
38+
// The aud claim may be a single string or an array (RFC 7519 §4.1.3);
39+
// ParseClaims normalises both to a slice. Non-URL audiences (e.g. an OAuth
40+
// client_id like "entire-cli") are skipped; the first URL-shaped audience is
41+
// validated strictly. A token with no URL-shaped aud is rejected with a clear
42+
// error rather than silently falling back to context resolution.
43+
func CoreURLFromEnvToken(rawToken string) (string, error) {
44+
claims, err := tokens.ParseClaims(rawToken)
45+
if err != nil {
46+
return "", fmt.Errorf("parse %s claims: %w", EnvTokenVar, err)
47+
}
48+
for _, aud := range claims.Audience {
49+
u, perr := url.Parse(aud)
50+
if perr != nil || u.Scheme == "" {
51+
// Opaque (non-URL) audience such as an OAuth client_id — skip it.
52+
continue
53+
}
54+
// URL-shaped: enforce the strict origin rules. A URL-shaped-but-invalid
55+
// aud is a hard error (fail closed), never silently skipped.
56+
return validateCoreAudience(u)
57+
}
58+
return "", fmt.Errorf("%s must be a login or sa-session JWT whose aud is the home-region URL; found no URL-shaped audience claim", EnvTokenVar)
59+
}
60+
61+
// validateCoreAudience enforces that u is a safe entire-core origin and
62+
// returns its canonical form (scheme://host, no trailing slash).
63+
func validateCoreAudience(u *url.URL) (string, error) {
64+
switch {
65+
case u.Scheme != "https":
66+
return "", fmt.Errorf("%s aud %q must use https; refusing to exchange the token over %s", EnvTokenVar, u.Redacted(), u.Scheme)
67+
case u.Host == "":
68+
return "", fmt.Errorf("%s aud %q has no host", EnvTokenVar, u.Redacted())
69+
case u.User != nil:
70+
return "", fmt.Errorf("%s aud %q must not contain userinfo", EnvTokenVar, u.Redacted())
71+
case u.Path != "" && u.Path != "/":
72+
return "", fmt.Errorf("%s aud %q must be a bare origin with no path", EnvTokenVar, u.Redacted())
73+
case u.RawQuery != "":
74+
return "", fmt.Errorf("%s aud %q must not contain query parameters", EnvTokenVar, u.Redacted())
75+
case u.Fragment != "":
76+
return "", fmt.Errorf("%s aud %q must not contain a fragment", EnvTokenVar, u.Redacted())
77+
}
78+
return strings.TrimRight(u.Scheme+"://"+u.Host, "/"), nil
79+
}
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
package auth
2+
3+
import (
4+
"encoding/base64"
5+
"encoding/json"
6+
"testing"
7+
8+
"github.com/stretchr/testify/assert"
9+
"github.com/stretchr/testify/require"
10+
)
11+
12+
func TestCoreURLFromEnvToken(t *testing.T) {
13+
t.Parallel()
14+
15+
tests := []struct {
16+
name string
17+
aud any // nil omits the aud claim entirely
18+
want string
19+
wantErr bool
20+
}{
21+
{
22+
name: "https string aud",
23+
aud: "https://core.us.entire.io",
24+
want: "https://core.us.entire.io",
25+
},
26+
{
27+
name: "https aud trailing slash trimmed",
28+
aud: "https://core.us.entire.io/",
29+
want: "https://core.us.entire.io",
30+
},
31+
{
32+
name: "array aud skips opaque, picks URL-shaped https",
33+
aud: []string{"entire-cli", "https://core.eu.entire.io"},
34+
want: "https://core.eu.entire.io",
35+
},
36+
{
37+
name: "http aud rejected (cleartext)",
38+
aud: "http://core.us.entire.io",
39+
wantErr: true,
40+
},
41+
{
42+
name: "aud with path rejected",
43+
aud: "https://core.us.entire.io/oauth/token",
44+
wantErr: true,
45+
},
46+
{
47+
name: "aud with query rejected",
48+
aud: "https://core.us.entire.io?x=1",
49+
wantErr: true,
50+
},
51+
{
52+
name: "aud with fragment rejected",
53+
aud: "https://core.us.entire.io#frag",
54+
wantErr: true,
55+
},
56+
{
57+
name: "aud with userinfo rejected",
58+
aud: "https://user:pass@core.us.entire.io",
59+
wantErr: true,
60+
},
61+
{
62+
name: "url-shaped non-https aud fails closed even with later https entry",
63+
aud: []string{"http://evil.example.com", "https://core.us.entire.io"},
64+
wantErr: true,
65+
},
66+
{
67+
name: "opaque string aud rejected",
68+
aud: "some-opaque-audience",
69+
wantErr: true,
70+
},
71+
{
72+
name: "array of opaque audiences rejected",
73+
aud: []string{"aud-a", "aud-b"},
74+
wantErr: true,
75+
},
76+
{
77+
name: "missing aud rejected",
78+
aud: nil,
79+
wantErr: true,
80+
},
81+
}
82+
83+
for _, tc := range tests {
84+
t.Run(tc.name, func(t *testing.T) {
85+
t.Parallel()
86+
payload := map[string]any{"sub": "ci-runner"}
87+
if tc.aud != nil {
88+
payload["aud"] = tc.aud
89+
}
90+
raw, err := json.Marshal(payload)
91+
require.NoError(t, err)
92+
token := makeJWT(t, string(raw))
93+
94+
got, err := CoreURLFromEnvToken(token)
95+
if tc.wantErr {
96+
require.Error(t, err)
97+
assert.Contains(t, err.Error(), EnvTokenVar)
98+
return
99+
}
100+
require.NoError(t, err)
101+
assert.Equal(t, tc.want, got)
102+
})
103+
}
104+
}
105+
106+
func TestCoreURLFromEnvToken_MalformedToken(t *testing.T) {
107+
t.Parallel()
108+
_, err := CoreURLFromEnvToken("not-a-jwt")
109+
require.Error(t, err)
110+
assert.Contains(t, err.Error(), EnvTokenVar)
111+
}
112+
113+
func TestCoreURLFromEnvToken_DoesNotTrim(t *testing.T) {
114+
t.Parallel()
115+
// Trimming is the caller's job (done once at the env-var read site in
116+
// resolveCreds). This function takes the token verbatim, so a padded value
117+
// is a malformed JWT here — guards against re-introducing a redundant trim.
118+
token := makeJWT(t, `{"sub":"ci-runner","aud":"https://core.us.entire.io"}`)
119+
_, err := CoreURLFromEnvToken(" " + token + "\n")
120+
require.Error(t, err)
121+
assert.Contains(t, err.Error(), EnvTokenVar)
122+
}
123+
124+
func TestCoreURLFromEnvToken_RejectsAlgNone(t *testing.T) {
125+
t.Parallel()
126+
// alg:none with a URL-shaped aud must still be rejected at the parse layer.
127+
enc := base64.RawURLEncoding
128+
token := enc.EncodeToString([]byte(`{"alg":"none"}`)) + "." +
129+
enc.EncodeToString([]byte(`{"aud":"https://core.us.entire.io"}`)) + "."
130+
_, err := CoreURLFromEnvToken(token)
131+
require.Error(t, err)
132+
assert.Contains(t, err.Error(), EnvTokenVar)
133+
}

cmd/git-remote-entire/main.go

Lines changed: 99 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -90,29 +90,12 @@ func run(args []string) int {
9090
Transport: httpclient.NewTransport(skipTLS),
9191
}
9292

93-
// Bridge any pre-contexts.json login so the resolver can find it.
94-
if _, err := auth.MigrateLegacyLoginContext(); err != nil {
95-
debuglog.Printf("legacy login migration: %v", err)
96-
}
97-
98-
// Resolve which login context authenticates this cluster: the cluster's
99-
// cores are taken from the cluster_cores.json cache (or a live
100-
// /.well-known fetch on miss/expiry), then the account is selected from
101-
// local contexts — active context if eligible, else the sole eligible
102-
// one, else an explicit-choice error.
103-
cfgDir := contexts.DefaultConfigDir()
104-
clusterCtx, err := clusterdiscovery.ResolveContextForCluster(ctx, cfgDir, discovery.DefaultCacheDir(), parsedURL.Host, httpClient, debuglog.Printf)
93+
creds, err := resolveCreds(ctx, parsedURL, clusterBaseURL, httpClient)
10594
if err != nil {
10695
fmt.Fprintf(os.Stderr, "fatal: %v\n", err)
10796
return 128
10897
}
10998

110-
// Mint repo-scoped tokens by exchanging the context's login JWT at its
111-
// core's /oauth/token, cached per (repo, action) for this invocation.
112-
creds := repocreds.New(clusterCtx.CoreURL, clusterBaseURL, func(context.Context) (string, error) {
113-
return auth.LoginTokenForContext(clusterCtx)
114-
}, httpClient)
115-
11699
setAuth := func(req *http.Request) error {
117100
action := gitActionFromRequest(req)
118101
if action == "" {
@@ -179,6 +162,104 @@ func parseProtocolVersion(raw string, warn io.Writer) int {
179162
return defaultVersion
180163
}
181164

165+
// resolveCreds builds the repo-scoped token cache, choosing the auth source:
166+
//
167+
// - ENTIRE_TOKEN set: use the env JWT verbatim as the login token, deriving
168+
// the login server URL from its aud claim. Skips contexts.json and the keyring
169+
// entirely — the CI / workload-identity path. A non-URL aud is a hard
170+
// error, never a silent fallback to context resolution.
171+
// - otherwise: resolve the login context for this cluster from contexts.json
172+
// (migrating any pre-contexts.json login first) and exchange its stored
173+
// login JWT.
174+
func resolveCreds(ctx context.Context, parsedURL *url.URL, clusterBaseURL string, httpClient *http.Client) (*repocreds.Cache, error) {
175+
// Presence of ENTIRE_TOKEN is the signal: if it's set at all (LookupEnv,
176+
// not Getenv, so we can tell set-empty from unset), we commit to the
177+
// env-token path and any failure to use it is fatal — never a silent
178+
// fallback to context auth, which would mask a misconfigured CI runner.
179+
// Read and trim once here, the only place we touch it, so every downstream
180+
// consumer (aud derivation and the exchanged subject_token) sees the
181+
// cleaned value; a trailing newline from $(cat token) is common. An empty
182+
// or whitespace-only value fails closed.
183+
if raw, ok := os.LookupEnv(auth.EnvTokenVar); ok {
184+
envToken := strings.TrimSpace(raw)
185+
if envToken == "" {
186+
return nil, fmt.Errorf("%s is set but blank", auth.EnvTokenVar)
187+
}
188+
return resolveEnvTokenCreds(ctx, envToken, parsedURL.Host, clusterBaseURL, discovery.DefaultCacheDir(), httpClient)
189+
}
190+
191+
// Bridge any pre-contexts.json login so the resolver can find it.
192+
if _, err := auth.MigrateLegacyLoginContext(); err != nil {
193+
debuglog.Printf("legacy login migration: %v", err)
194+
}
195+
196+
// Resolve which login context authenticates this cluster: the cluster's
197+
// login servers are taken from the cluster_cores.json cache (or a live
198+
// /.well-known fetch on miss/expiry), then the account is selected from
199+
// local contexts — active context if eligible, else the sole eligible
200+
// one, else an explicit-choice error.
201+
cfgDir := contexts.DefaultConfigDir()
202+
clusterCtx, err := clusterdiscovery.ResolveContextForCluster(ctx, cfgDir, discovery.DefaultCacheDir(), parsedURL.Host, httpClient, debuglog.Printf)
203+
if err != nil {
204+
return nil, err //nolint:wrapcheck // ResolveContextForCluster already returns a user-facing error; preserved verbatim for the "fatal: <msg>" surface
205+
}
206+
207+
// Mint repo-scoped tokens by exchanging the context's login JWT at its
208+
// login server's /oauth/token, cached per (repo, action) for this invocation.
209+
return repocreds.New(clusterCtx.CoreURL, clusterBaseURL, func(context.Context) (string, error) {
210+
return auth.LoginTokenForContext(clusterCtx)
211+
}, httpClient), nil
212+
}
213+
214+
// resolveEnvTokenCreds builds the repo-cred cache for the ENTIRE_TOKEN path.
215+
// Split out of resolveCreds with explicit clusterHost/cacheDir params (no
216+
// os.Getenv / DefaultCacheDir globals) so the trust gate below is unit-testable
217+
// against a fake well-known server.
218+
//
219+
// SECURITY: coreURL is derived from the env token's *unverified* aud claim, and
220+
// it becomes the host the token is POSTed to as a subject_token during
221+
// exchange. Before trusting it, we confirm the core is one the target cluster
222+
// actually advertises — anchored to the clone URL's host the user typed (TLS to
223+
// its /.well-known/entire-cluster.json), not to the token's own claims. Without
224+
// this gate a forged aud could redirect the token to an attacker-chosen host.
225+
//
226+
// The gate is only as strong as that TLS verification: with
227+
// ENTIRE_TLS_SKIP_VERIFY=true (a local-dev escape hatch) the well-known fetch
228+
// is no longer authenticated, so a MITM could advertise an attacker host as a
229+
// trusted core. Do not combine ENTIRE_TOKEN with ENTIRE_TLS_SKIP_VERIFY in
230+
// CI / workload-identity environments.
231+
func resolveEnvTokenCreds(ctx context.Context, envToken, clusterHost, clusterBaseURL, cacheDir string, httpClient *http.Client) (*repocreds.Cache, error) {
232+
coreURL, err := auth.CoreURLFromEnvToken(envToken)
233+
if err != nil {
234+
return nil, err //nolint:wrapcheck // CoreURLFromEnvToken already returns a user-facing, ENTIRE_TOKEN-prefixed error
235+
}
236+
cores, err := clusterdiscovery.ResolveClusterCores(ctx, cacheDir, clusterHost, httpClient, debuglog.Printf)
237+
if err != nil {
238+
return nil, err //nolint:wrapcheck // ResolveClusterCores returns a user-facing discovery error
239+
}
240+
if !coreTrusted(coreURL, cores) {
241+
return nil, fmt.Errorf("%s aud %q is not a trusted core for cluster %s (advertised: %s); the token belongs to a different cluster",
242+
auth.EnvTokenVar, coreURL, clusterHost, strings.Join(cores, ", "))
243+
}
244+
debuglog.Printf("authenticating via %s; core=%s", auth.EnvTokenVar, coreURL)
245+
return repocreds.New(coreURL, clusterBaseURL, func(context.Context) (string, error) {
246+
return envToken, nil
247+
}, httpClient), nil
248+
}
249+
250+
// coreTrusted reports whether coreURL is in the cluster's advertised core
251+
// set, comparing on trailing-slash-insensitive equality to match how core
252+
// URLs are compared elsewhere (contexts.ContextsForIssuer, auth.sameIssuer).
253+
func coreTrusted(coreURL string, trusted []string) bool {
254+
want := strings.TrimRight(coreURL, "/")
255+
for _, t := range trusted {
256+
if strings.TrimRight(t, "/") == want {
257+
return true
258+
}
259+
}
260+
return false
261+
}
262+
182263
// gitActionFromRequest classifies a smart-HTTP request as "pull" or "push"
183264
// so the right repo-scoped token can be minted. Returns "" when the
184265
// endpoint isn't a recognised git smart-HTTP route.

0 commit comments

Comments
 (0)