diff --git a/internal/cmd/auth/login/login.go b/internal/cmd/auth/login/login.go index 377df8f..f6b9934 100644 --- a/internal/cmd/auth/login/login.go +++ b/internal/cmd/auth/login/login.go @@ -13,6 +13,7 @@ import ( "github.com/zeabur/cli/internal/cmdutil" "github.com/zeabur/cli/pkg/api" + "github.com/zeabur/cli/pkg/auth" "github.com/zeabur/cli/pkg/config" ) @@ -56,23 +57,15 @@ func RunLogin(f *cmdutil.Factory, opts *Options) error { f.Log.Debug("Running login in non-interactive mode") } + storedToken := f.Config.GetTokenString() if f.LoggedIn() { - f.ApiClient = opts.NewClient(f.Config.GetTokenString()) - user, err := f.ApiClient.GetUserInfo(context.Background()) - if err != nil { - var graphqlErrors graphql.Errors - if errors.As(err, &graphqlErrors) && - len(graphqlErrors) > 0 && - strings.HasPrefix(graphqlErrors[0].Message, "401 Unauthorized") { - f.Log.Debug("Token is expired or invalid, need to login again") - } else { - return fmt.Errorf("failed to get user info: %w", err) - } + if f.Interactive && auth.IsLegacyAPIKey(storedToken) { + f.Log.Info("Your stored credential is a deprecated legacy API key, logging in again to obtain an access token") } else { - f.Log.Debugw("Already logged in", "token", f.Config.GetTokenString(), "user", user) - f.Log.Infof("Already logged in as %s, "+ - "if you want to use a different account, please logout first", user.Name) - return nil + stillValid, err := reportExistingLogin(f, opts, storedToken) + if err != nil || stillValid { + return err + } } } @@ -96,6 +89,9 @@ func RunLogin(f *cmdutil.Factory, opts *Options) error { if tokenString = f.Config.GetTokenString(); tokenString == "" { return fmt.Errorf("please set ZEABUR_TOKEN environment variable or use --token flag to set token") } + if auth.IsLegacyAPIKey(tokenString) { + f.Log.Warn(auth.LegacyAPIKeyDeprecationMessage) + } } f.Log.Debugw("Token", "token", tokenString) @@ -115,3 +111,32 @@ func RunLogin(f *cmdutil.Factory, opts *Options) error { return nil } + +// reportExistingLogin validates the stored token and reports whether it can still be used. +// An unauthorized response means the caller should proceed with a fresh login. +func reportExistingLogin(f *cmdutil.Factory, opts *Options, token string) (bool, error) { + f.ApiClient = opts.NewClient(token) + user, err := f.ApiClient.GetUserInfo(context.Background()) + if err != nil { + if isUnauthorized(err) { + f.Log.Debug("Token is expired or invalid, need to login again") + return false, nil + } + return false, fmt.Errorf("failed to get user info: %w", err) + } + + f.Log.Debugw("Already logged in", "token", token, "user", user) + f.Log.Infof("Already logged in as %s, "+ + "if you want to use a different account, please logout first", user.Name) + if auth.IsLegacyAPIKey(token) { + f.Log.Warn(auth.LegacyAPIKeyDeprecationMessage) + } + return true, nil +} + +func isUnauthorized(err error) bool { + var graphqlErrors graphql.Errors + return errors.As(err, &graphqlErrors) && + len(graphqlErrors) > 0 && + strings.HasPrefix(graphqlErrors[0].Message, "401 Unauthorized") +} diff --git a/internal/cmd/auth/login/login_test.go b/internal/cmd/auth/login/login_test.go new file mode 100644 index 0000000..2af6e41 --- /dev/null +++ b/internal/cmd/auth/login/login_test.go @@ -0,0 +1,76 @@ +package login_test + +import ( + "context" + "strings" + "testing" + + "github.com/spf13/viper" + "go.uber.org/zap/zapcore" + "go.uber.org/zap/zaptest" + + "github.com/zeabur/cli/internal/cmd/auth/login" + "github.com/zeabur/cli/internal/cmdutil" + "github.com/zeabur/cli/pkg/api" + "github.com/zeabur/cli/pkg/auth" + "github.com/zeabur/cli/pkg/log" + "github.com/zeabur/cli/pkg/model" + "github.com/zeabur/cli/pkg/zcontext" +) + +type stubConfig struct { + token string +} + +func (s *stubConfig) GetTokenString() string { return s.token } +func (s *stubConfig) SetTokenString(token string) { s.token = token } +func (s *stubConfig) GetUser() string { return "" } +func (s *stubConfig) SetUser(string) {} +func (s *stubConfig) GetUsername() string { return "" } +func (s *stubConfig) SetUsername(string) {} +func (s *stubConfig) GetContext() zcontext.Context { return zcontext.NewViperContext(viper.New()) } +func (s *stubConfig) Write() error { return nil } + +type stubClient struct { + api.Client +} + +func (stubClient) GetUserInfo(context.Context) (*model.User, error) { + return &model.User{Name: "tester"}, nil +} + +func runLoginWithStoredToken(t *testing.T, token string, interactive bool) string { + t.Helper() + + buf := &zaptest.Buffer{} + f := &cmdutil.Factory{ + Log: log.NewForUT(buf, zapcore.InfoLevel), + Config: &stubConfig{token: token}, + } + f.Interactive = interactive + + opts := &login.Options{NewClient: func(string) api.Client { return stubClient{} }} + if err := login.RunLogin(f, opts); err != nil { + t.Fatalf("RunLogin returned error: %v", err) + } + return buf.String() +} + +func TestRunLogin_NonInteractiveLegacyTokenWarns(t *testing.T) { + out := runLoginWithStoredToken(t, "sk-legacy", false) + + if !strings.Contains(out, "Already logged in as tester") { + t.Errorf("expected already-logged-in notice, got:\n%s", out) + } + if !strings.Contains(out, auth.LegacyAPIKeyDeprecationMessage) { + t.Errorf("expected legacy deprecation warning, got:\n%s", out) + } +} + +func TestRunLogin_NonInteractiveAccessTokenDoesNotWarn(t *testing.T) { + out := runLoginWithStoredToken(t, "zat_user_secret", false) + + if strings.Contains(out, auth.LegacyAPIKeyDeprecationMessage) { + t.Errorf("unexpected legacy deprecation warning, got:\n%s", out) + } +} diff --git a/internal/cmd/auth/status/status.go b/internal/cmd/auth/status/status.go index 101480b..96fdc89 100644 --- a/internal/cmd/auth/status/status.go +++ b/internal/cmd/auth/status/status.go @@ -8,6 +8,7 @@ import ( "github.com/zeabur/cli/internal/cmdutil" "github.com/zeabur/cli/pkg/api" + "github.com/zeabur/cli/pkg/auth" ) // statusOptions contains the input to the status command. @@ -57,6 +58,10 @@ func runStatus(f *cmdutil.Factory, opts *statusOptions) error { f.Log.Infof("Logged in as %s (%s), email: %s, plan: %s, credit: $%.2f", user.Name, user.Username, user.Email, user.Subscription.Plan, float64(user.Credit)/100) + if auth.IsLegacyAPIKey(f.Config.GetTokenString()) { + f.Log.Warn(auth.LegacyAPIKeyDeprecationMessage) + } + if opts.verbose { f.Printer.Table(user.Header(), user.Rows()) } diff --git a/internal/cmd/root/root.go b/internal/cmd/root/root.go index 217541f..581049b 100644 --- a/internal/cmd/root/root.go +++ b/internal/cmd/root/root.go @@ -32,6 +32,7 @@ import ( workspaceCmd "github.com/zeabur/cli/internal/cmd/workspace" "github.com/zeabur/cli/internal/cmdutil" "github.com/zeabur/cli/pkg/api" + "github.com/zeabur/cli/pkg/auth" "github.com/zeabur/cli/pkg/config" "github.com/zeabur/cli/pkg/fill" "github.com/zeabur/cli/pkg/log" @@ -98,6 +99,9 @@ func NewCmdRoot(f *cmdutil.Factory, version, commit, date string) (*cobra.Comman } f.Config.SetTokenString(tokenString) } + if !f.JSON && auth.IsLegacyAPIKey(f.Config.GetTokenString()) { + f.Log.Warn(auth.LegacyAPIKeyDeprecationMessage) + } // set up the client f.ApiClient = api.New(f.Config.GetTokenString()) diff --git a/pkg/auth/callback.go b/pkg/auth/callback.go index fd95f72..2661914 100644 --- a/pkg/auth/callback.go +++ b/pkg/auth/callback.go @@ -39,7 +39,7 @@ func (s *CallbackServer) Serve() error { mux := http.NewServeMux() mux.HandleFunc("POST /callback", func(w http.ResponseWriter, r *http.Request) { s.tokenCh <- TokenResponse{ - Token: r.FormValue("api_key"), + Token: tokenFromForm(r), State: r.FormValue("state"), } @@ -52,6 +52,15 @@ func (s *CallbackServer) Serve() error { return http.Serve(s.listener, mux) } +// tokenFromForm accepts both the current `access_token` field and the `api_key` +// field posted by dashboard builds that predate access tokens. +func tokenFromForm(r *http.Request) string { + if token := r.FormValue("access_token"); token != "" { + return token + } + return r.FormValue("api_key") +} + func (s *CallbackServer) Close() error { err := s.listener.Close() close(s.tokenCh) diff --git a/pkg/auth/client.go b/pkg/auth/client.go index e8b6c05..1916ed0 100644 --- a/pkg/auth/client.go +++ b/pkg/auth/client.go @@ -22,7 +22,7 @@ type ImplicitFlowClient struct { } func NewImplicitFlowClient(callbackServer *CallbackServer) *ImplicitFlowClient { - endpointURL, err := url.Parse(ZeaburApiKeyConfirmEndpoint) + endpointURL, err := url.Parse(ZeaburAccessTokenConfirmEndpoint) if err != nil { panic(fmt.Sprintf("failed to parse endpoint URL (internal error): %v", err)) } diff --git a/pkg/auth/const.go b/pkg/auth/const.go index 609799f..186116f 100644 --- a/pkg/auth/const.go +++ b/pkg/auth/const.go @@ -2,7 +2,10 @@ package auth import "github.com/zeabur/cli/pkg/constant" -// Zeabur "API Key Confirmation" endpoint constants const ( - ZeaburApiKeyConfirmEndpoint = constant.ZeaburDashURL + "/auth/api-key/confirm" + // ZeaburAccessTokenConfirmEndpoint is the dashboard page that mints an access token for the CLI. + // The path keeps its historical "api-key" name so older CLI builds continue to resolve it. + ZeaburAccessTokenConfirmEndpoint = constant.ZeaburDashURL + "/auth/api-key/confirm" + + ZeaburAccessTokenSettingsURL = constant.ZeaburDashURL + "/account/api-keys" ) diff --git a/pkg/auth/token.go b/pkg/auth/token.go new file mode 100644 index 0000000..bf370cc --- /dev/null +++ b/pkg/auth/token.go @@ -0,0 +1,18 @@ +package auth + +import "strings" + +const ( + // AccessTokenPrefix marks a scoped personal access token issued by createAccessToken. + AccessTokenPrefix = "zat_" + // LegacyAPIKeyPrefix marks an unscoped API key issued by the deprecated createApiKey. + LegacyAPIKeyPrefix = "sk-" +) + +const LegacyAPIKeyDeprecationMessage = "You are authenticated with a legacy API key, which is deprecated. " + + "Run `zeabur auth login` to migrate to an access token, " + + "or create one at " + ZeaburAccessTokenSettingsURL + " for use with ZEABUR_TOKEN." + +func IsLegacyAPIKey(token string) bool { + return strings.HasPrefix(token, LegacyAPIKeyPrefix) +} diff --git a/pkg/auth/token_test.go b/pkg/auth/token_test.go new file mode 100644 index 0000000..657ebfe --- /dev/null +++ b/pkg/auth/token_test.go @@ -0,0 +1,64 @@ +package auth_test + +import ( + "context" + "net/http" + "net/url" + "strings" + "testing" + "time" + + "github.com/zeabur/cli/pkg/auth" +) + +func TestIsLegacyAPIKey(t *testing.T) { + cases := map[string]bool{ + "sk-abcdefghijklmnopqrstuvwxyz012": true, + "zat_5f1a_abcdefghijklmnopqrstuvwxyz012": false, + "eyJhbGciOiJIUzI1NiJ9.x.y": false, + "": false, + } + for token, want := range cases { + if got := auth.IsLegacyAPIKey(token); got != want { + t.Errorf("IsLegacyAPIKey(%q) = %v, want %v", token, got, want) + } + } +} + +func TestCallbackServerAcceptsTokenFields(t *testing.T) { + cases := []struct { + name string + form url.Values + want string + }{ + {"access_token preferred", url.Values{"access_token": {"zat_1"}, "api_key": {"sk-1"}}, "zat_1"}, + {"api_key fallback", url.Values{"api_key": {"sk-1"}}, "sk-1"}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + server, err := auth.NewCallbackServer() + if err != nil { + t.Fatal(err) + } + go func() { _ = server.Serve() }() + defer func() { _ = server.Close() }() + + c.form.Set("state", "abc") + resp, err := http.Post(server.GetCallbackURL(), "application/x-www-form-urlencoded", strings.NewReader(c.form.Encode())) + if err != nil { + t.Fatal(err) + } + _ = resp.Body.Close() + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + got, err := server.WaitForToken(ctx) + if err != nil { + t.Fatal(err) + } + if got.Token != c.want || got.State != "abc" { + t.Errorf("got %+v, want token %q state abc", got, c.want) + } + }) + } +}