Allow multiple token sources instead of a single one #36

Open
opened 2026-05-04 22:20:31 +02:00 by heiko · 1 comment
Owner

Currently the config supports a single token source for API authentication. This enhancement would allow specifying multiple token sources (e.g., multiple go.schlittermann.de/heiko/secret references, environment variables, files) with a fallback chain, while remaining backward compatible with configs that specify a single token source.

Currently the config supports a single token source for API authentication. This enhancement would allow specifying multiple token sources (e.g., multiple `go.schlittermann.de/heiko/secret` references, environment variables, files) with a fallback chain, while remaining backward compatible with configs that specify a single token source.
Author
Owner

Implementation sketch

Current state

Config.Repo.Token is a single string holding a secret-scheme URI (e.g. env:FORGEJO_TOKEN, netrc:git.example.com). loader.NewHTTPClient resolves it once via secret.Get and mutates the field in place with the plain token value. Three callsites then pass that plain string to forgejo.NewClient and builder.CloneRepo.


Config syntax (backward compatible)

The YAML token: key stays as-is but gains the ability to accept either a scalar (old) or a sequence (new):

# old — still works
repo:
  token: env:FORGEJO_TOKEN

# new — tried in order, first that resolves wins
repo:
  token:
    - env:FORGEJO_TOKEN
    - netrc:git.schlittermann.de
    - file:/home/user/.tokens/git

Go changes

1. internal/config/config.go — new custom type

// TokenSources holds one or more secret-scheme URIs.
// It unmarshals from either a YAML scalar or a sequence,
// so single-token configs need no changes.
type TokenSources []string

func (t *TokenSources) UnmarshalYAML(value *yaml.Node) error {
    switch value.Kind {
    case yaml.ScalarNode:
        *t = TokenSources{value.Value}
        return nil
    case yaml.SequenceNode:
        var ss []string
        if err := value.Decode(&ss); err != nil {
            return err
        }
        *t = TokenSources(ss)
        return nil
    }
    return fmt.Errorf("repo.token: expected string or list of strings")
}

Change Config.Repo.Token from string to TokenSources:

Repo struct {
    APIURL      string
    Restrict30x []string
    Owner, Name string
    Token       TokenSources  // was: string
}

2. internal/cmd/loader/loader.go — resolution helper + changed signature

// resolveToken tries each source in order and returns the first
// non-empty value. Returns an error only when all sources fail.
func resolveToken(sources config.TokenSources) (string, error) {
    var errs []error
    for _, src := range sources {
        tok, err := secret.Get(src)
        if err == nil && tok != "" {
            return tok, nil
        }
        if err != nil {
            errs = append(errs, fmt.Errorf("%s: %w", src, err))
        }
    }
    if len(errs) > 0 {
        return "", errors.Join(errs...)
    }
    return "", fmt.Errorf("no token source yielded a value")
}

NewHTTPClient stops mutating cf and instead returns the resolved token:

// NewHTTPClient returns the HTTP client and the resolved API token.
func NewHTTPClient(cf *config.Config) (*http.Client, string, error) {
    token, err := resolveToken(cf.Repo.Token)
    if err != nil {
        return nil, "", fmt.Errorf("expanding repo.token: %w", err)
    }

    check, err := apiclient.CheckRedirect(cf.Repo.Restrict30x)
    if err != nil {
        return nil, "", fmt.Errorf("trusted redirects: %w", err)
    }

    return &http.Client{CheckRedirect: check}, token, nil
}

3. Callsites — thread token through instead of reading cf.Repo.Token

Every caller of NewHTTPClient changes from:

htc, err := loader.NewHTTPClient(cf)
// ...later...
forgejo.NewClient(cf.Repo.APIURL, cf.Repo.Token, ...)

to:

htc, token, err := loader.NewHTTPClient(cf)
// ...later...
forgejo.NewClient(cf.Repo.APIURL, token, ...)

The three affected spots are:

  • internal/cmd/status/status.go:39
  • internal/cmd/release/release.go:296 (builder.CloneRepo)
  • internal/cmd/release/release.go:617 (forgejo.NewClient)

Error-reporting note

When all sources fail, the error should list which sources were tried and why each failed, so the user can debug their config. errors.Join (Go 1.20+) is sufficient; no need for a custom type.

What does NOT need to change

  • The secret package — it already handles all resolution schemes.
  • The default.yml — the #token: comment line stays as-is; the doc comment can gain a list-form example.
  • The forgejo and apiclient packages — they receive a plain resolved string, same as today.

Estimated scope

~60 lines changed across 4 files, plus tests for UnmarshalYAML and resolveToken.

## Implementation sketch ### Current state `Config.Repo.Token` is a single `string` holding a `secret`-scheme URI (e.g. `env:FORGEJO_TOKEN`, `netrc:git.example.com`). `loader.NewHTTPClient` resolves it once via `secret.Get` and mutates the field in place with the plain token value. Three callsites then pass that plain string to `forgejo.NewClient` and `builder.CloneRepo`. --- ### Config syntax (backward compatible) The YAML `token:` key stays as-is but gains the ability to accept either a scalar (old) or a sequence (new): ```yaml # old — still works repo: token: env:FORGEJO_TOKEN # new — tried in order, first that resolves wins repo: token: - env:FORGEJO_TOKEN - netrc:git.schlittermann.de - file:/home/user/.tokens/git ``` --- ### Go changes #### 1. `internal/config/config.go` — new custom type ```go // TokenSources holds one or more secret-scheme URIs. // It unmarshals from either a YAML scalar or a sequence, // so single-token configs need no changes. type TokenSources []string func (t *TokenSources) UnmarshalYAML(value *yaml.Node) error { switch value.Kind { case yaml.ScalarNode: *t = TokenSources{value.Value} return nil case yaml.SequenceNode: var ss []string if err := value.Decode(&ss); err != nil { return err } *t = TokenSources(ss) return nil } return fmt.Errorf("repo.token: expected string or list of strings") } ``` Change `Config.Repo.Token` from `string` to `TokenSources`: ```go Repo struct { APIURL string Restrict30x []string Owner, Name string Token TokenSources // was: string } ``` #### 2. `internal/cmd/loader/loader.go` — resolution helper + changed signature ```go // resolveToken tries each source in order and returns the first // non-empty value. Returns an error only when all sources fail. func resolveToken(sources config.TokenSources) (string, error) { var errs []error for _, src := range sources { tok, err := secret.Get(src) if err == nil && tok != "" { return tok, nil } if err != nil { errs = append(errs, fmt.Errorf("%s: %w", src, err)) } } if len(errs) > 0 { return "", errors.Join(errs...) } return "", fmt.Errorf("no token source yielded a value") } ``` `NewHTTPClient` stops mutating `cf` and instead returns the resolved token: ```go // NewHTTPClient returns the HTTP client and the resolved API token. func NewHTTPClient(cf *config.Config) (*http.Client, string, error) { token, err := resolveToken(cf.Repo.Token) if err != nil { return nil, "", fmt.Errorf("expanding repo.token: %w", err) } check, err := apiclient.CheckRedirect(cf.Repo.Restrict30x) if err != nil { return nil, "", fmt.Errorf("trusted redirects: %w", err) } return &http.Client{CheckRedirect: check}, token, nil } ``` #### 3. Callsites — thread `token` through instead of reading `cf.Repo.Token` Every caller of `NewHTTPClient` changes from: ```go htc, err := loader.NewHTTPClient(cf) // ...later... forgejo.NewClient(cf.Repo.APIURL, cf.Repo.Token, ...) ``` to: ```go htc, token, err := loader.NewHTTPClient(cf) // ...later... forgejo.NewClient(cf.Repo.APIURL, token, ...) ``` The three affected spots are: - `internal/cmd/status/status.go:39` - `internal/cmd/release/release.go:296` (`builder.CloneRepo`) - `internal/cmd/release/release.go:617` (`forgejo.NewClient`) --- ### Error-reporting note When all sources fail, the error should list *which* sources were tried and why each failed, so the user can debug their config. `errors.Join` (Go 1.20+) is sufficient; no need for a custom type. ### What does NOT need to change - The `secret` package — it already handles all resolution schemes. - The `default.yml` — the `#token:` comment line stays as-is; the doc comment can gain a list-form example. - The `forgejo` and `apiclient` packages — they receive a plain resolved string, same as today. --- ### Estimated scope ~60 lines changed across 4 files, plus tests for `UnmarshalYAML` and `resolveToken`.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
heiko/gogogo#36
No description provided.