package rotate import ( "bytes" "context" "encoding/json" "fmt" "io" "net/http" "net/url" "strings" "time" "incredigo/internal/discover" "incredigo/internal/vault" ) // Gitea rotates a Gitea personal access token (PAT) via the Gitea API by // CREATING a new token, verifying it, then DELETING the old one — the canonical // provider-API rotation pattern (create-new → verify → revoke-old), and the first // driver whose RevokeOld is a real destructive API call. // // The credential's secret is a single self-contained line (so it round-trips // through the existing single-line gopass sink unchanged): // // https://:@[:port]/?name=[&pw=] // // - the PAT being rotated (the userinfo password). // - the Gitea username (userinfo user). // - scheme+host the API base (http for the loopback sandbox; https in prod). // - name= the token's Gitea NAME — required to DELETE it (the API deletes // by name/id, and the token VALUE can't be mapped back to a name). // incredigo writes the new name on every rotation, so the chain is // self-sustaining; a bare discovered token with no name degrades to // the guided worklist for the old-token deletion. // - pw= OPTIONAL Gitea account password. Gitea historically requires // BASIC auth (password) to create/delete tokens; if present it is // used for those management calls. If absent, the driver tries // token auth (works on Gitea versions that allow a scoped token to // manage tokens). Verify always uses the token itself. // // SECURITY: the token (and optional mgmt password) live only in the vault and the // query string of the in-vault secret; they never reach Identity/Meta/logs. HTTP // bodies carrying the new token are drained into the vault, not stringified beyond // the unavoidable JSON decode. type Gitea struct { // HTTPClient is injectable for tests / custom TLS. Defaults to a 15s-timeout // client using http.DefaultTransport. HTTPClient *http.Client } // init self-registers the driver. Availability alone changes nothing; the // `rotate --execute` + INCREDIGO_ALLOW_EXECUTE=1 gate still applies. func init() { Register(&Gitea{}) } // Name identifies the driver in plans and audit records. func (g *Gitea) Name() string { return "gitea" } // Detect claims credentials tagged Source == "gitea". func (g *Gitea) Detect(c discover.Credential) bool { return c.Source == "gitea" } func (g *Gitea) client() *http.Client { if g.HTTPClient != nil { return g.HTTPClient } return &http.Client{Timeout: 15 * time.Second} } // giteaSecret is the parsed credential blob. None of its fields are ever logged. type giteaSecret struct { base string // scheme://host[:port] user string token string name string // token name (for DELETE) pw string // optional mgmt password (basic auth) } // parseGiteaSecret reads the single-line blob from the vault and decomposes it. func parseGiteaSecret(v *vault.Vault, h *vault.Handle) (giteaSecret, error) { buf, err := v.Open(h) if err != nil { return giteaSecret{}, err } u, err := url.Parse(strings.TrimSpace(string(buf.Bytes()))) if err != nil { return giteaSecret{}, fmt.Errorf("gitea: parse secret url: %w", err) } if u.Scheme != "http" && u.Scheme != "https" { return giteaSecret{}, fmt.Errorf("gitea: unexpected scheme %q", u.Scheme) } if u.User == nil { return giteaSecret{}, fmt.Errorf("gitea: secret has no user:token") } tok, _ := u.User.Password() if tok == "" { return giteaSecret{}, fmt.Errorf("gitea: secret has no token") } q := u.Query() return giteaSecret{ base: u.Scheme + "://" + u.Host, user: u.User.Username(), token: tok, name: q.Get("name"), pw: q.Get("pw"), }, nil } // build re-encodes a giteaSecret into the single-line blob form. func (s giteaSecret) build() string { u := &url.URL{ Scheme: schemeOf(s.base), Host: hostOf(s.base), User: url.UserPassword(s.user, s.token), Path: "/", } q := url.Values{} q.Set("name", s.name) if s.pw != "" { q.Set("pw", s.pw) } u.RawQuery = q.Encode() return u.String() } func schemeOf(base string) string { if i := strings.Index(base, "://"); i >= 0 { return base[:i] } return "https" } func hostOf(base string) string { if i := strings.Index(base, "://"); i >= 0 { return base[i+3:] } return base } // Rotate creates a NEW token at the Gitea API (authenticating with the old token // or, if a mgmt password is present, basic auth), and returns a vault handle to a // rebuilt blob carrying the new token + new name. It does not revoke the old token. func (g *Gitea) Rotate(ctx context.Context, c discover.Credential, v *vault.Vault) (*vault.Handle, error) { s, err := parseGiteaSecret(v, c.Secret) if err != nil { return nil, err } newName := fmt.Sprintf("incredigo-rotated-%d", time.Now().Unix()) body, _ := json.Marshal(map[string]any{ "name": newName, "scopes": []string{"write:user", "read:user"}, // enough to manage tokens on Gitea ≥1.19; ignored by older }) req, err := http.NewRequestWithContext(ctx, http.MethodPost, s.base+"/api/v1/users/"+url.PathEscape(s.user)+"/tokens", bytes.NewReader(body)) if err != nil { return nil, err } req.Header.Set("Content-Type", "application/json") g.authManage(req, s) resp, err := g.client().Do(req) if err != nil { return nil, fmt.Errorf("gitea: create token: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("gitea: create token: unexpected status %s", resp.Status) } var created struct { Name string `json:"name"` SHA1 string `json:"sha1"` } if err := json.NewDecoder(resp.Body).Decode(&created); err != nil { return nil, fmt.Errorf("gitea: decode created token: %w", err) } if created.SHA1 == "" { return nil, fmt.Errorf("gitea: create token: empty token in response") } ns := s // carry base/user/pw forward ns.token = created.SHA1 ns.name = created.Name if ns.name == "" { ns.name = newName } return v.Store([]byte(ns.build())), nil } // Verify proves the newly minted token authenticates by calling GET /api/v1/user // with it and checking the returned login matches. func (g *Gitea) Verify(ctx context.Context, newSecret *vault.Handle, v *vault.Vault) error { s, err := parseGiteaSecret(v, newSecret) if err != nil { return err } req, err := http.NewRequestWithContext(ctx, http.MethodGet, s.base+"/api/v1/user", nil) if err != nil { return err } req.Header.Set("Authorization", "token "+s.token) resp, err := g.client().Do(req) if err != nil { return fmt.Errorf("gitea verify: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return fmt.Errorf("gitea verify: GET /user status %s", resp.Status) } var who struct { Login string `json:"login"` } if err := json.NewDecoder(resp.Body).Decode(&who); err != nil { return fmt.Errorf("gitea verify: decode user: %w", err) } if s.user != "" && who.Login != "" && !strings.EqualFold(who.Login, s.user) { return fmt.Errorf("gitea verify: token authenticates as %q, expected %q", who.Login, s.user) } return nil } // RevokeOld deletes the OLD token by name. The old token is still valid at this // point (revoke runs only after the new token is stored+verified), so it is used // to authenticate its own deletion unless a mgmt password is configured. func (g *Gitea) RevokeOld(ctx context.Context, c discover.Credential, v *vault.Vault) error { s, err := parseGiteaSecret(v, c.Secret) if err != nil { return err } if s.name == "" { return fmt.Errorf("gitea revoke: old token has no recorded name — delete it manually (guided)") } req, err := http.NewRequestWithContext(ctx, http.MethodDelete, s.base+"/api/v1/users/"+url.PathEscape(s.user)+"/tokens/"+url.PathEscape(s.name), nil) if err != nil { return err } g.authManage(req, s) resp, err := g.client().Do(req) if err != nil { return fmt.Errorf("gitea revoke: %w", err) } defer func() { io.Copy(io.Discard, resp.Body); resp.Body.Close() }() if resp.StatusCode != http.StatusNoContent && resp.StatusCode != http.StatusOK { return fmt.Errorf("gitea revoke: delete token status %s", resp.Status) } return nil } // authManage sets auth for token-management calls (create/delete): basic auth with // the mgmt password if present (Gitea's historical requirement), else token auth. func (g *Gitea) authManage(req *http.Request, s giteaSecret) { if s.pw != "" { req.SetBasicAuth(s.user, s.pw) return } req.Header.Set("Authorization", "token "+s.token) }