@@ -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