Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,17 @@ to follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
server, and edge subjects; a draft API). This surface is not in the public
OpenAPI spec, so these first-class commands are the way to reach it.

- **Access-token cache cuts audit-log noise.** c1i now caches the OAuth access
token in the OS keyring when available, with a hardened `0600` file fallback
on Unix-like headless hosts, and reuses it across invocations until it
nears expiry. A burst of one-shot commands no longer mints — and logs — a
`client_credentials` grant each time. Measured live, 12 sequential commands
drop from 12 authentication events to 1. A cached token the server rejects
(clock skew, or a revocation) is dropped and re-minted once automatically.
Opt out with `C1I_NO_TOKEN_CACHE=1`. The cached token is strictly
shorter-lived than the client secret already stored beside it, so it widens
no exposure.

### Fixed

- **MCP gateway dry-run safety.** `mcp gateway call` now rejects `--dry-run`
Expand Down
13 changes: 13 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1229,6 +1229,19 @@ never written to disk — a new one is minted per invocation.
file backend transparently. `c1i auth status` tells you which source served
the active credentials.

### Token cache

To avoid minting a fresh OAuth token on every invocation — each mint writes a
`client_credentials` event to the tenant's audit log — `c1i` caches the access
token and reuses it until it nears expiry. It uses the OS keyring when
available. On Unix-like hosts without a usable keyring, it instead uses a
hardened `0600` file under the config directory (`~/.config/c1i/tokens/` on
Linux). A cached token the server rejects (clock skew, or a revoked credential)
is dropped and re-minted once automatically.
The token is strictly shorter-lived than the client secret already stored
beside it. Set `C1I_NO_TOKEN_CACHE=1` to disable caching and mint per
invocation.

## Shell Completion

```sh
Expand Down
7 changes: 7 additions & 0 deletions cmd/agents.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,13 @@ subcommands that also take an external server's address — that one is
Credentials resolve in this order: `C1I_CLIENT_ID` + `C1I_CLIENT_SECRET` env
vars (read-only — c1i never writes them), the OS keyring, then a `0600` file
used automatically where no keyring exists (headless Linux, CI, containers).
Only the bearer c1i attaches automatically for REST commands is cached and
reused across invocations until it nears expiry, so a run of one-shot commands
does not write a `client_credentials` audit event each time. It uses the OS
keychain when available; on Unix-like hosts without a usable keyring, it uses
a hardened `0600` file fallback. `auth token` and `mcp gateway` always mint a
fresh bearer and never persist it.
`C1I_NO_TOKEN_CACHE=1` disables the REST-client cache.
`c1i auth login` to authenticate; then `c1i auth whoami` before doing
anything else — it reports both the identity you're acting as (principleId,
plus userId when the principal has one; email and displayName only when a
Expand Down
2 changes: 2 additions & 0 deletions cmd/auth_logout.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package cmd
import (
"fmt"

"github.com/ConductorOne/c1i/internal/client"
"github.com/ConductorOne/c1i/internal/config"
"github.com/ConductorOne/c1i/internal/keychain"
"github.com/spf13/cobra"
Expand All @@ -19,6 +20,7 @@ C1I_CLIENT_SECRET) are not affected.`,
if err != nil {
return err
}
client.ClearCachedToken(baseURL)
service := config.KeychainService(baseURL)
removed, err := keychain.Delete(service)
if err != nil {
Expand Down
68 changes: 65 additions & 3 deletions internal/client/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,17 @@ func loadCredentials(baseURL string) (clientID, clientSecret string, err error)
return clientID, clientSecret, nil
}

// ClearCachedToken removes the cached REST bearer for the credentials currently
// selected for baseURL. It is best-effort so logout can still remove credentials
// if their cache path cannot be resolved.
func ClearCachedToken(baseURL string) {
clientID, clientSecret, err := loadCredentials(baseURL)
if err != nil {
return
}
tokensource.InvalidateCachedToken(baseURL, clientID, clientSecret)
}

// isTokenError reports whether err is (or wraps) a rejected client_credentials
// grant. It powers two things: telling transport.Do to fail fast on it rather
// than burn the retry budget on credentials that won't fix themselves, and
Expand Down Expand Up @@ -172,21 +183,72 @@ func New(ctx context.Context, baseURL string, opts ...Option) (*Client, error) {
return nil, err
}

tokenSource, err := tokensource.NewTokenSource(ctx, clientID, clientSecret, baseURL, transportOpts(opts)...)
tokenSource, err := tokensource.NewCachingTokenSource(ctx, clientID, clientSecret, baseURL, transportOpts(opts)...)
if err != nil {
return nil, &AuthError{fmt.Errorf("creating token source: %w", err)}
}

oauthClient := oauth2.NewClient(ctx, tokenSource)
// oauth2.NewClient wraps its source in a second ReuseTokenSource. The
// cacheTokenSource already owns caching and needs Invalidate to take effect
// before a 401 retry, so compose the transport directly.
var base http.RoundTripper = &oauth2.Transport{Source: tokenSource}
// A cached token can be locally-unexpired yet server-rejected; recover by
// dropping it and re-minting once. Only when the source caches.
if inv, ok := tokenSource.(tokensource.Invalidator); ok {
base = &retryOnTokenReject{base: base, invalidate: inv.Invalidate}
}
cfg := resolve(opts)
t := transport.New(oauthClient.Transport,
t := transport.New(base,
transport.WithMaxRetries(cfg.maxRetries),
transport.WithDebug(cfg.debug),
transport.WithNonRetryable(isTokenError),
)
return &Client{t: t, baseURL: baseURL}, nil
}

// retryOnTokenReject recovers from a cached access token the server refuses.
// On the first 401 it invalidates the cache and retries once with a freshly
// minted token; a second 401 is a real auth failure and is returned. A 401 is
// rejected before the request is processed, so retrying a mutation is safe. It
// retries only when the body can be rewound, and never on 403 (authenticated
// but forbidden -- a new token of the same identity would not help).
type retryOnTokenReject struct {
base http.RoundTripper
invalidate func()
}

func (r *retryOnTokenReject) RoundTrip(req *http.Request) (*http.Response, error) {
resp, err := r.base.RoundTrip(req)
if err != nil || resp.StatusCode != http.StatusUnauthorized {
return resp, err
}
req2, ok := rewind(req)
if !ok {
return resp, nil
}
_ = resp.Body.Close()
r.invalidate()
return r.base.RoundTrip(req2)
}

// rewind clones req with a fresh copy of its body for a retry, reporting false
// when the body exists but cannot be replayed.
func rewind(req *http.Request) (*http.Request, bool) {
clone := req.Clone(req.Context())
if req.Body == nil || req.Body == http.NoBody {
return clone, true
}
if req.GetBody == nil {
return nil, false
}
body, err := req.GetBody()
if err != nil {
return nil, false
}
clone.Body = body
return clone, true
}

// NewForTesting returns a *Client that sends every request through hc's
// transport to baseURL, bypassing loadCredentials and the OAuth mint New
// performs. It exists so a test in another package (e.g. cmd's) can drive a
Expand Down
179 changes: 179 additions & 0 deletions internal/client/retry_token_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
package client

import (
"io"
"net/http"
"strings"
"testing"

"golang.org/x/oauth2"
)

// seqRT returns a programmed status code per call and records each request's
// body and Authorization header, so a test can assert both the retry decision
// and that a rewound body (and a fresh bearer) was resent intact.
type seqRT struct {
codes []int
calls int
bodies []string
auths []string
}

func (s *seqRT) RoundTrip(req *http.Request) (*http.Response, error) {
body := ""
if req.Body != nil {
b, _ := io.ReadAll(req.Body)
_ = req.Body.Close()
body = string(b)
}
s.bodies = append(s.bodies, body)
s.auths = append(s.auths, req.Header.Get("Authorization"))
code := s.codes[s.calls]
s.calls++
return &http.Response{StatusCode: code, Body: http.NoBody, Header: make(http.Header)}, nil
}

func newReq(t *testing.T, body string) *http.Request {
t.Helper()
var r *http.Request
var err error
if body == "" {
r, err = http.NewRequest(http.MethodGet, "https://x/y", nil)
} else {
r, err = http.NewRequest(http.MethodPost, "https://x/y", strings.NewReader(body))
}
if err != nil {
t.Fatal(err)
}
return r
}

func TestRetryOnTokenReject_401ThenSuccess(t *testing.T) {
base := &seqRT{codes: []int{401, 200}}
inv := 0
rt := &retryOnTokenReject{base: base, invalidate: func() { inv++ }}
resp, err := rt.RoundTrip(newReq(t, ""))
if err != nil {
t.Fatal(err)
}
if resp.StatusCode != 200 {
t.Errorf("status = %d, want 200 after re-mint", resp.StatusCode)
}
if inv != 1 {
t.Errorf("invalidate calls = %d, want 1", inv)
}
if base.calls != 2 {
t.Errorf("base calls = %d, want 2", base.calls)
}
}

func TestRetryOnTokenReject_SecondRejectIsReturned(t *testing.T) {
base := &seqRT{codes: []int{401, 401}}
inv := 0
rt := &retryOnTokenReject{base: base, invalidate: func() { inv++ }}
resp, err := rt.RoundTrip(newReq(t, ""))
if err != nil {
t.Fatal(err)
}
if resp.StatusCode != 401 {
t.Errorf("status = %d, want the second 401 returned", resp.StatusCode)
}
if inv != 1 || base.calls != 2 {
t.Errorf("invalidate=%d base=%d, want exactly one retry (1 and 2)", inv, base.calls)
}
}

func TestRetryOnTokenReject_NoRetryOnOtherStatuses(t *testing.T) {
for _, code := range []int{200, 403, 404, 500} {
base := &seqRT{codes: []int{code}}
inv := 0
rt := &retryOnTokenReject{base: base, invalidate: func() { inv++ }}
resp, err := rt.RoundTrip(newReq(t, ""))
if err != nil {
t.Fatal(err)
}
if resp.StatusCode != code || inv != 0 || base.calls != 1 {
t.Errorf("code %d: got status=%d inv=%d calls=%d, want pass-through (no invalidate, one call)", code, resp.StatusCode, inv, base.calls)
}
}
}

func TestRetryOnTokenReject_ReplaysBody(t *testing.T) {
base := &seqRT{codes: []int{401, 200}}
rt := &retryOnTokenReject{base: base, invalidate: func() {}}
if _, err := rt.RoundTrip(newReq(t, `{"k":"v"}`)); err != nil {
t.Fatal(err)
}
if len(base.bodies) != 2 {
t.Fatalf("captured %d bodies, want 2", len(base.bodies))
}
if base.bodies[0] != `{"k":"v"}` || base.bodies[1] != `{"k":"v"}` {
t.Errorf("bodies = %q, want the same JSON resent on retry", base.bodies)
}
}

// rotatingTokenSource models cacheTokenSource after an invalidation. The retry
// must invoke it again, not let an outer oauth2.ReuseTokenSource resend stale.
type rotatingTokenSource struct {
invalidated bool
}

func (s *rotatingTokenSource) Token() (*oauth2.Token, error) {
token := "stale"
if s.invalidated {
token = "fresh"
}
return &oauth2.Token{AccessToken: token, TokenType: "Bearer"}, nil
}

// TestRetryOnTokenReject_ThroughOAuth2Transport drives the real oauth2.Transport
// beneath retryOnTokenReject (the exact composition client.New builds), proving
// a replayed POST gets a freshly attached bearer after cache invalidation.
func TestRetryOnTokenReject_ThroughOAuth2Transport(t *testing.T) {
base := &seqRT{codes: []int{401, 200}}
source := &rotatingTokenSource{}
oauthT := &oauth2.Transport{Source: source, Base: base}
rt := &retryOnTokenReject{
base: oauthT,
invalidate: func() {
source.invalidated = true
},
}
req, err := http.NewRequest(http.MethodPost, "https://x/y", strings.NewReader(`{"k":"v"}`))
if err != nil {
t.Fatal(err)
}
resp, err := rt.RoundTrip(req)
if err != nil {
t.Fatal(err)
}
if resp.StatusCode != 200 {
t.Fatalf("status=%d, want 200", resp.StatusCode)
}
if len(base.bodies) != 2 || base.bodies[0] != `{"k":"v"}` || base.bodies[1] != `{"k":"v"}` {
t.Errorf("bodies=%q, want the JSON resent on retry", base.bodies)
}
if len(base.auths) != 2 || base.auths[0] != "Bearer stale" || base.auths[1] != "Bearer fresh" {
t.Errorf("auth headers=%q, want stale then fresh bearer", base.auths)
}
}

// unrewindableReq has a body but no GetBody, so it cannot be replayed.
func TestRetryOnTokenReject_NoRetryWhenBodyUnrewindable(t *testing.T) {
base := &seqRT{codes: []int{401, 200}}
inv := 0
rt := &retryOnTokenReject{base: base, invalidate: func() { inv++ }}
req, err := http.NewRequest(http.MethodPost, "https://x/y", nil)
if err != nil {
t.Fatal(err)
}
req.Body = io.NopCloser(strings.NewReader("payload"))
req.GetBody = nil
resp, err := rt.RoundTrip(req)
if err != nil {
t.Fatal(err)
}
if resp.StatusCode != 401 || inv != 0 || base.calls != 1 {
t.Errorf("got status=%d inv=%d calls=%d, want the first 401 returned with no retry", resp.StatusCode, inv, base.calls)
}
}
Loading
Loading