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
55 changes: 40 additions & 15 deletions internal/cmd/auth/login/login.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand Down Expand Up @@ -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
}
}
}

Expand All @@ -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)
Expand All @@ -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")
}
76 changes: 76 additions & 0 deletions internal/cmd/auth/login/login_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
5 changes: 5 additions & 0 deletions internal/cmd/auth/status/status.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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())
}
Expand Down
4 changes: 4 additions & 0 deletions internal/cmd/root/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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())

Expand Down
11 changes: 10 additions & 1 deletion pkg/auth/callback.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
}

Expand All @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion pkg/auth/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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))
}
Expand Down
7 changes: 5 additions & 2 deletions pkg/auth/const.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
18 changes: 18 additions & 0 deletions pkg/auth/token.go
Original file line number Diff line number Diff line change
@@ -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)
}
64 changes: 64 additions & 0 deletions pkg/auth/token_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
})
}
}