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.
126 lines
4.1 KiB
Go
126 lines
4.1 KiB
Go
package client
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"net/http"
|
|
"time"
|
|
)
|
|
|
|
// LoginRequest is the body for POST /api/v1/auth/login.
|
|
type LoginRequest struct {
|
|
Email string `json:"email"`
|
|
Password string `json:"password"`
|
|
}
|
|
|
|
// LoginResponse is the body returned by POST /api/v1/auth/login.
|
|
//
|
|
// Token is the JWT access token; RefreshToken renews it via Auth.Refresh.
|
|
type LoginResponse struct {
|
|
Success bool `json:"success"`
|
|
Message string `json:"message,omitempty"`
|
|
User *AuthUser `json:"user,omitempty"`
|
|
Tenant *AuthTenant `json:"tenant,omitempty"`
|
|
Token string `json:"token,omitempty"`
|
|
RefreshToken string `json:"refresh_token,omitempty"`
|
|
}
|
|
|
|
// AuthUser is the principal returned by /auth/login and /auth/me.
|
|
//
|
|
// Fields mirror the server's UserInfo projection (no PasswordHash).
|
|
type AuthUser struct {
|
|
ID string `json:"id"`
|
|
Username string `json:"username"`
|
|
Email string `json:"email"`
|
|
Avatar string `json:"avatar,omitempty"`
|
|
TenantID uint64 `json:"tenant_id"`
|
|
IsActive bool `json:"is_active"`
|
|
CanAccessAllTenants bool `json:"can_access_all_tenants,omitempty"`
|
|
CreatedAt time.Time `json:"created_at,omitempty"`
|
|
}
|
|
|
|
// AuthTenant is the tenant projection returned alongside AuthUser.
|
|
type AuthTenant struct {
|
|
ID uint64 `json:"id"`
|
|
Name string `json:"name"`
|
|
}
|
|
|
|
// CurrentUserResponse is the body of GET /api/v1/auth/me.
|
|
type CurrentUserResponse struct {
|
|
Success bool `json:"success"`
|
|
Data struct {
|
|
User *AuthUser `json:"user,omitempty"`
|
|
Tenant *AuthTenant `json:"tenant,omitempty"`
|
|
} `json:"data"`
|
|
}
|
|
|
|
// Canonical paths for the auth endpoints. Exposed so callers building
|
|
// HTTP middleware (e.g. the CLI's 401-retry transport) can classify
|
|
// requests without re-hardcoding the literals.
|
|
const (
|
|
PathAuthLogin = "/api/v1/auth/login"
|
|
PathAuthRefresh = "/api/v1/auth/refresh"
|
|
)
|
|
|
|
// RefreshTokenResponse is the body of POST /api/v1/auth/refresh.
|
|
type RefreshTokenResponse struct {
|
|
Success bool `json:"success"`
|
|
Message string `json:"message,omitempty"`
|
|
AccessToken string `json:"access_token"`
|
|
RefreshToken string `json:"refresh_token"`
|
|
}
|
|
|
|
// Login authenticates with email + password and returns the JWT access token,
|
|
// refresh token, and principal info. Maps to POST /api/v1/auth/login.
|
|
//
|
|
// Used by `weknora auth login`.
|
|
func (c *Client) Login(ctx context.Context, req LoginRequest) (*LoginResponse, error) {
|
|
resp, err := c.doRequest(ctx, http.MethodPost, "/api/v1/auth/login", req, nil)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("login request: %w", err)
|
|
}
|
|
var out LoginResponse
|
|
if err := parseResponse(resp, &out); err != nil {
|
|
return nil, err
|
|
}
|
|
return &out, nil
|
|
}
|
|
|
|
// GetCurrentUser returns the currently authenticated principal and the
|
|
// tenant projection. Maps to GET /api/v1/auth/me; the bearer token must
|
|
// already be set on the client (use WithBearerToken).
|
|
//
|
|
// Used by `weknora auth status` and `weknora whoami`.
|
|
func (c *Client) GetCurrentUser(ctx context.Context) (*CurrentUserResponse, error) {
|
|
resp, err := c.doRequest(ctx, http.MethodGet, "/api/v1/auth/me", nil, nil)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("get current user: %w", err)
|
|
}
|
|
var out CurrentUserResponse
|
|
if err := parseResponse(resp, &out); err != nil {
|
|
return nil, err
|
|
}
|
|
return &out, nil
|
|
}
|
|
|
|
// RefreshToken renews the JWT access token using a refresh token.
|
|
// Maps to POST /api/v1/auth/refresh.
|
|
//
|
|
// Callers (`weknora auth refresh`) read the refresh token from secrets,
|
|
// invoke this method, and persist both new tokens. The SDK does not touch
|
|
// the secrets store directly — it stays a transport-only layer.
|
|
func (c *Client) RefreshToken(ctx context.Context, refreshToken string) (*RefreshTokenResponse, error) {
|
|
body := struct {
|
|
RefreshToken string `json:"refreshToken"`
|
|
}{RefreshToken: refreshToken}
|
|
resp, err := c.doRequest(ctx, http.MethodPost, "/api/v1/auth/refresh", body, nil)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("refresh token: %w", err)
|
|
}
|
|
var out RefreshTokenResponse
|
|
if err := parseResponse(resp, &out); err != nil {
|
|
return nil, err
|
|
}
|
|
return &out, nil
|
|
}
|