4c26bc9ecc
Two halves of v0.3 roadmap item 3-2. (1) `weknora auth refresh` — explicit token renewal: Reads the stored refresh_token, spends it via POST /api/v1/auth/refresh (OAuth refresh-token grant), and persists both new tokens. API-key contexts rejected with input.invalid_argument (no refresh semantic). NOTE: gh CLI has `gh auth refresh` but with different semantics — gh's variant is an OAuth scope expansion / re-prompt via the browser (verified against the gh manual). The two share a name but solve different problems; there's no direct gh parallel for refresh-token grant because gh's PAT/OAuth-app model doesn't expose a short-lived access_token + refresh_token pair to clients. Error mapping: - no current context → auth.unauthenticated - --name unknown → local.context_not_found - missing refresh in keyring → auth.token_expired (hint: re-login) - server Success=false → auth.token_expired - network → network.error Envelope omits the token values (would leak into agent transcripts). (2) AuthRetryTransport — transparent retry: Wraps the SDK http.Client. On a 401 from a non-/auth/* endpoint: - JWT context: read refresh token, hit /auth/refresh, persist new pair, replay original request with new bearer. - API-key context: pass through (no refresh semantic). - Non-replayable body (req.GetBody == nil): pass through. - /auth/login or /auth/refresh: pass through (no recursion). Concurrent 401s are singleflight-coalesced via sync.Mutex — 5 parallel calls trigger exactly 1 refresh. SDK additions (additive, non-breaking): - WithTransport(rt http.RoundTripper) ClientOption. - PathAuthLogin / PathAuthRefresh constants (cli/internal/cmdutil/authretry imports them so the CLI and SDK can't drift on path strings). Refactor surfaced by the post-commit reviewer round: - cmdutil.RefreshAndPersist(ctx, store, refresher, ctxName) — the load-refresh → call-SDK → persist-pair sequence was duplicated between the standalone `auth refresh` and the transport's refresh closure; collapsed to one canonical implementation. - refreshFn signature takes context.Context so Ctrl+C during a transparent refresh cancels. - AuthRetryTransport.CurrentToken() removed — never called. 8 + 8 + 8 unit tests cover happy path / refresh-fail / auth-endpoint skip / api-key passthrough / singleflight under concurrency / non- replayable-body fallback. Roadmap: 3-2.
63 lines
2.2 KiB
Go
63 lines
2.2 KiB
Go
package cmdutil
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
|
|
"github.com/Tencent/WeKnora/cli/internal/secrets"
|
|
sdk "github.com/Tencent/WeKnora/client"
|
|
)
|
|
|
|
// Refresher is the narrow SDK surface RefreshAndPersist depends on.
|
|
// *sdk.Client satisfies it implicitly; tests inject fakes.
|
|
type Refresher interface {
|
|
RefreshToken(ctx context.Context, refreshToken string) (*sdk.RefreshTokenResponse, error)
|
|
}
|
|
|
|
// RefreshAndPersist reads the stored refresh token for ctxName, exchanges it
|
|
// for a new access + refresh pair via refresher, and writes both back to the
|
|
// secrets store. Returns the new access token (the refresh is already
|
|
// persisted as a side-effect, so callers only need the access value to
|
|
// retry the original request).
|
|
//
|
|
// Single canonical implementation shared by `weknora auth refresh` and the
|
|
// AuthRetryTransport's refresh closure — both used to inline the same
|
|
// six-step sequence with subtly diverging error wording.
|
|
func RefreshAndPersist(ctx context.Context, store secrets.Store, refresher Refresher, ctxName string) (string, error) {
|
|
refresh, err := store.Get(ctxName, "refresh")
|
|
if errors.Is(err, secrets.ErrNotFound) || refresh == "" {
|
|
return "", &Error{
|
|
Code: CodeAuthTokenExpired,
|
|
Message: "refresh token missing for context " + ctxName,
|
|
Hint: "run `weknora auth login` to re-authenticate",
|
|
}
|
|
}
|
|
if err != nil {
|
|
return "", Wrapf(CodeLocalKeychainDenied, err, "load refresh token")
|
|
}
|
|
|
|
resp, err := refresher.RefreshToken(ctx, refresh)
|
|
if err != nil {
|
|
return "", Wrapf(CodeNetworkError, err, "refresh access token")
|
|
}
|
|
if resp == nil || !resp.Success || resp.AccessToken == "" || resp.RefreshToken == "" {
|
|
msg := "refresh token rejected"
|
|
if resp != nil && resp.Message != "" {
|
|
msg = "refresh token rejected: " + resp.Message
|
|
}
|
|
return "", &Error{
|
|
Code: CodeAuthTokenExpired,
|
|
Message: msg,
|
|
Hint: "run `weknora auth login` to re-authenticate",
|
|
}
|
|
}
|
|
|
|
if err := store.Set(ctxName, "access", resp.AccessToken); err != nil {
|
|
return "", Wrapf(CodeLocalKeychainDenied, err, "save access token")
|
|
}
|
|
if err := store.Set(ctxName, "refresh", resp.RefreshToken); err != nil {
|
|
return "", Wrapf(CodeLocalKeychainDenied, err, "save refresh token")
|
|
}
|
|
return resp.AccessToken, nil
|
|
}
|