From ee224fbe3b43915dd681637cfeb1429b493144bf Mon Sep 17 00:00:00 2001 From: Jordan Stephens Date: Tue, 19 Aug 2025 15:54:39 -0700 Subject: [PATCH 01/14] move login and agree_tos into login package --- src/pkg/cli/connect.go | 12 +++++++---- src/pkg/login/login.go | 47 ++++++++++++++++++++++++++++++++++++++++++ src/pkg/setup/setup.go | 3 +-- 3 files changed, 56 insertions(+), 6 deletions(-) diff --git a/src/pkg/cli/connect.go b/src/pkg/cli/connect.go index 178503b74..0ad585433 100644 --- a/src/pkg/cli/connect.go +++ b/src/pkg/cli/connect.go @@ -7,15 +7,19 @@ import ( "github.com/DefangLabs/defang/src/pkg/cli/client/byoc/aws" "github.com/DefangLabs/defang/src/pkg/cli/client/byoc/do" "github.com/DefangLabs/defang/src/pkg/cli/client/byoc/gcp" - "github.com/DefangLabs/defang/src/pkg/cluster" + "github.com/DefangLabs/defang/src/pkg/login" "github.com/DefangLabs/defang/src/pkg/term" "github.com/DefangLabs/defang/src/pkg/track" "github.com/DefangLabs/defang/src/pkg/types" ) -func Connect(ctx context.Context, addr string) (*client.GrpcClient, error) { - tenantName, host := cluster.SplitTenantHost(addr) - accessToken := cluster.GetExistingToken(addr) +const DefaultCluster = login.DefaultCluster + +var DefangFabric = login.DefangFabric + +func Connect(ctx context.Context, cluster string) (*client.GrpcClient, error) { + tenantName, host := login.SplitTenantHost(cluster) + accessToken := login.GetExistingToken(cluster) term.Debug("Using tenant", tenantName, "for cluster", host) grpcClient := client.NewGrpcClient(host, accessToken, tenantName) track.Tracker = grpcClient // Update track client diff --git a/src/pkg/login/login.go b/src/pkg/login/login.go index 39ba0cc5b..6ffa06eba 100644 --- a/src/pkg/login/login.go +++ b/src/pkg/login/login.go @@ -4,8 +4,12 @@ import ( "context" "errors" "fmt" + "net" "os" + "path/filepath" + "strings" + "github.com/DefangLabs/defang/src/pkg" "github.com/DefangLabs/defang/src/pkg/auth" "github.com/DefangLabs/defang/src/pkg/cli" "github.com/DefangLabs/defang/src/pkg/cli/client" @@ -14,10 +18,53 @@ import ( "github.com/DefangLabs/defang/src/pkg/github" "github.com/DefangLabs/defang/src/pkg/term" "github.com/DefangLabs/defang/src/pkg/track" + "github.com/DefangLabs/defang/src/pkg/types" defangv1 "github.com/DefangLabs/defang/src/protos/io/defang/v1" "github.com/bufbuild/connect-go" ) +const DefaultCluster = "fabric-prod1.defang.dev" + +var DefangFabric = pkg.Getenv("DEFANG_FABRIC", DefaultCluster) + +func SplitTenantHost(cluster string) (types.TenantName, string) { + tenant := types.DEFAULT_TENANT + parts := strings.SplitN(cluster, "@", 2) + if len(parts) == 2 { + tenant, cluster = types.TenantName(parts[0]), parts[1] + } + if cluster == "" { + cluster = DefangFabric + } + if _, _, err := net.SplitHostPort(cluster); err != nil { + cluster = cluster + ":443" // default to https + } + return tenant, cluster +} + +func getTokenFile(fabric string) string { + if host, _, _ := net.SplitHostPort(fabric); host != "" { + fabric = host + } + return filepath.Join(client.StateDir, fabric) +} + +func GetExistingToken(fabric string) string { + var accessToken = os.Getenv("DEFANG_ACCESS_TOKEN") + + if accessToken == "" { + tokenFile := getTokenFile(fabric) + + term.Debug("Reading access token from file", tokenFile) + all, _ := os.ReadFile(tokenFile) + accessToken = string(all) + } else { + term.Debug("Using access token from env DEFANG_ACCESS_TOKEN") + } + + return accessToken +} + type LoginFlow = auth.LoginFlow type AuthService interface { diff --git a/src/pkg/setup/setup.go b/src/pkg/setup/setup.go index 8d961cb4b..b19a3a75e 100644 --- a/src/pkg/setup/setup.go +++ b/src/pkg/setup/setup.go @@ -226,8 +226,7 @@ func beforeGenerate(directory string) { } func (s *SetupClient) MigrateFromHeroku(ctx context.Context) (SetupResult, error) { - err := login.InteractiveRequireLoginAndToS(ctx, s.Fabric, s.Cluster) - if err != nil { + if err := login.InteractiveLogin(ctx, s.Fabric, s.Cluster); err != nil { return SetupResult{}, err } From 45e9dec76261a0e2887b270bf45907694025f146 Mon Sep 17 00:00:00 2001 From: Jordan Stephens Date: Tue, 19 Aug 2025 16:16:59 -0700 Subject: [PATCH 02/14] factor out RequireLoginAndToS --- src/cmd/cli/command/commands.go | 47 +++++++++++++++++++++++++++++---- 1 file changed, 42 insertions(+), 5 deletions(-) diff --git a/src/cmd/cli/command/commands.go b/src/cmd/cli/command/commands.go index 4a4451a45..e8d965606 100644 --- a/src/cmd/cli/command/commands.go +++ b/src/cmd/cli/command/commands.go @@ -380,16 +380,53 @@ var RootCmd = &cobra.Command{ return nil } - if nonInteractive { - err = client.CheckLoginAndToS(ctx) - } else { - err = login.InteractiveRequireLoginAndToS(ctx, client, getCluster()) - } + err = RequireLoginAndToS(cmd.Context()) return err }, } +func RequireLoginAndToS(ctx context.Context) error { + var err error + if err = client.CheckLoginAndToS(ctx); err != nil { + if nonInteractive { + return err + } + // Login interactively now; only do this for authorization-related errors + if connect.CodeOf(err) == connect.CodeUnauthenticated { + term.Debug("Server error:", err) + term.Warn("Please log in to continue.") + term.ResetWarnings() // clear any previous warnings so we don't show them again + + defer func() { track.Cmd(nil, "Login", P("reason", err)) }() + if err = login.InteractiveLogin(ctx, client, getCluster()); err != nil { + return err + } + + // Reconnect with the new token + if client, err = cli.Connect(ctx, getCluster()); err != nil { + return err + } + + if err = client.CheckLoginAndToS(ctx); err == nil { // recheck (new token = new user) + return nil // success + } + } + + // Check if the user has agreed to the terms of service and show a prompt if needed + if connect.CodeOf(err) == connect.CodeFailedPrecondition { + term.Warn(prettyError(err)) + + defer func() { track.Cmd(nil, "Terms", P("reason", err)) }() + if err = login.InteractiveAgreeToS(ctx, client); err != nil { + return err // fatal + } + } + } + + return err +} + var loginCmd = &cobra.Command{ Use: "login", Args: cobra.NoArgs, From 9fc0563fa0e2125a1f29bb323f84d3370e56b5ba Mon Sep 17 00:00:00 2001 From: Jordan Stephens Date: Tue, 19 Aug 2025 16:45:50 -0700 Subject: [PATCH 03/14] move prettyError into client --- src/cmd/cli/command/commands.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/cmd/cli/command/commands.go b/src/cmd/cli/command/commands.go index e8d965606..3d21f8098 100644 --- a/src/cmd/cli/command/commands.go +++ b/src/cmd/cli/command/commands.go @@ -415,7 +415,7 @@ func RequireLoginAndToS(ctx context.Context) error { // Check if the user has agreed to the terms of service and show a prompt if needed if connect.CodeOf(err) == connect.CodeFailedPrecondition { - term.Warn(prettyError(err)) + term.Warn(cliClient.PrettyError(err)) defer func() { track.Cmd(nil, "Terms", P("reason", err)) }() if err = login.InteractiveAgreeToS(ctx, client); err != nil { From e3aad14cdbcf4ae8302e413ff7123bb4301e2827 Mon Sep 17 00:00:00 2001 From: Jordan Stephens Date: Tue, 19 Aug 2025 16:47:30 -0700 Subject: [PATCH 04/14] push up interactivity check --- src/cmd/cli/command/commands.go | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/cmd/cli/command/commands.go b/src/cmd/cli/command/commands.go index 3d21f8098..5d6371550 100644 --- a/src/cmd/cli/command/commands.go +++ b/src/cmd/cli/command/commands.go @@ -380,18 +380,19 @@ var RootCmd = &cobra.Command{ return nil } - err = RequireLoginAndToS(cmd.Context()) + if nonInteractive { + err = client.CheckLoginAndToS(ctx) + } else { + err = InteractiveRequireLoginAndToS(ctx) + } return err }, } -func RequireLoginAndToS(ctx context.Context) error { +func InteractiveRequireLoginAndToS(ctx context.Context) error { var err error if err = client.CheckLoginAndToS(ctx); err != nil { - if nonInteractive { - return err - } // Login interactively now; only do this for authorization-related errors if connect.CodeOf(err) == connect.CodeUnauthenticated { term.Debug("Server error:", err) From 6245f8d988d7d26e4d4027bd3dd168e036487915 Mon Sep 17 00:00:00 2001 From: Jordan Stephens Date: Tue, 19 Aug 2025 17:05:26 -0700 Subject: [PATCH 05/14] break out cluster package --- src/pkg/cli/connect.go | 12 ++---- src/pkg/login/login.go | 88 ------------------------------------------ 2 files changed, 4 insertions(+), 96 deletions(-) diff --git a/src/pkg/cli/connect.go b/src/pkg/cli/connect.go index 0ad585433..178503b74 100644 --- a/src/pkg/cli/connect.go +++ b/src/pkg/cli/connect.go @@ -7,19 +7,15 @@ import ( "github.com/DefangLabs/defang/src/pkg/cli/client/byoc/aws" "github.com/DefangLabs/defang/src/pkg/cli/client/byoc/do" "github.com/DefangLabs/defang/src/pkg/cli/client/byoc/gcp" - "github.com/DefangLabs/defang/src/pkg/login" + "github.com/DefangLabs/defang/src/pkg/cluster" "github.com/DefangLabs/defang/src/pkg/term" "github.com/DefangLabs/defang/src/pkg/track" "github.com/DefangLabs/defang/src/pkg/types" ) -const DefaultCluster = login.DefaultCluster - -var DefangFabric = login.DefangFabric - -func Connect(ctx context.Context, cluster string) (*client.GrpcClient, error) { - tenantName, host := login.SplitTenantHost(cluster) - accessToken := login.GetExistingToken(cluster) +func Connect(ctx context.Context, addr string) (*client.GrpcClient, error) { + tenantName, host := cluster.SplitTenantHost(addr) + accessToken := cluster.GetExistingToken(addr) term.Debug("Using tenant", tenantName, "for cluster", host) grpcClient := client.NewGrpcClient(host, accessToken, tenantName) track.Tracker = grpcClient // Update track client diff --git a/src/pkg/login/login.go b/src/pkg/login/login.go index 6ffa06eba..a09b529f4 100644 --- a/src/pkg/login/login.go +++ b/src/pkg/login/login.go @@ -4,67 +4,17 @@ import ( "context" "errors" "fmt" - "net" "os" - "path/filepath" - "strings" - "github.com/DefangLabs/defang/src/pkg" "github.com/DefangLabs/defang/src/pkg/auth" - "github.com/DefangLabs/defang/src/pkg/cli" "github.com/DefangLabs/defang/src/pkg/cli/client" "github.com/DefangLabs/defang/src/pkg/cluster" "github.com/DefangLabs/defang/src/pkg/dryrun" "github.com/DefangLabs/defang/src/pkg/github" "github.com/DefangLabs/defang/src/pkg/term" - "github.com/DefangLabs/defang/src/pkg/track" - "github.com/DefangLabs/defang/src/pkg/types" defangv1 "github.com/DefangLabs/defang/src/protos/io/defang/v1" - "github.com/bufbuild/connect-go" ) -const DefaultCluster = "fabric-prod1.defang.dev" - -var DefangFabric = pkg.Getenv("DEFANG_FABRIC", DefaultCluster) - -func SplitTenantHost(cluster string) (types.TenantName, string) { - tenant := types.DEFAULT_TENANT - parts := strings.SplitN(cluster, "@", 2) - if len(parts) == 2 { - tenant, cluster = types.TenantName(parts[0]), parts[1] - } - if cluster == "" { - cluster = DefangFabric - } - if _, _, err := net.SplitHostPort(cluster); err != nil { - cluster = cluster + ":443" // default to https - } - return tenant, cluster -} - -func getTokenFile(fabric string) string { - if host, _, _ := net.SplitHostPort(fabric); host != "" { - fabric = host - } - return filepath.Join(client.StateDir, fabric) -} - -func GetExistingToken(fabric string) string { - var accessToken = os.Getenv("DEFANG_ACCESS_TOKEN") - - if accessToken == "" { - tokenFile := getTokenFile(fabric) - - term.Debug("Reading access token from file", tokenFile) - all, _ := os.ReadFile(tokenFile) - accessToken = string(all) - } else { - term.Debug("Using access token from env DEFANG_ACCESS_TOKEN") - } - - return accessToken -} - type LoginFlow = auth.LoginFlow type AuthService interface { @@ -157,41 +107,3 @@ func NonInteractiveGitHubLogin(ctx context.Context, client client.FabricClient, } return cluster.SaveAccessToken(fabric, resp.AccessToken) } - -func InteractiveRequireLoginAndToS(ctx context.Context, fabric client.FabricClient, addr string) error { - var err error - if err = fabric.CheckLoginAndToS(ctx); err != nil { - // Login interactively now; only do this for authorization-related errors - if connect.CodeOf(err) == connect.CodeUnauthenticated { - term.Debug("Server error:", err) - term.Warn("Please log in to continue.") - term.ResetWarnings() // clear any previous warnings so we don't show them again - - defer func() { track.Cmd(nil, "Login", P("reason", err)) }() - if err = InteractiveLogin(ctx, fabric, addr); err != nil { - return err - } - - // Reconnect with the new token - if fabric, err = cli.Connect(ctx, addr); err != nil { - return err - } - - if err = fabric.CheckLoginAndToS(ctx); err == nil { // recheck (new token = new user) - return nil // success - } - } - - // Check if the user has agreed to the terms of service and show a prompt if needed - if connect.CodeOf(err) == connect.CodeFailedPrecondition { - term.Warn(client.PrettyError(err)) - - defer func() { track.Cmd(nil, "Terms", P("reason", err)) }() - if err = InteractiveAgreeToS(ctx, fabric); err != nil { - return err // fatal - } - } - } - - return err -} From 9b1ba0ce8519f8edcba0ab65e938321d3b0d48c4 Mon Sep 17 00:00:00 2001 From: Jordan Stephens Date: Tue, 19 Aug 2025 17:10:08 -0700 Subject: [PATCH 06/14] pas fabric and cluster addr into InteractiveRequireLoginAndToS --- src/cmd/cli/command/commands.go | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/cmd/cli/command/commands.go b/src/cmd/cli/command/commands.go index 5d6371550..a59dfad69 100644 --- a/src/cmd/cli/command/commands.go +++ b/src/cmd/cli/command/commands.go @@ -383,16 +383,16 @@ var RootCmd = &cobra.Command{ if nonInteractive { err = client.CheckLoginAndToS(ctx) } else { - err = InteractiveRequireLoginAndToS(ctx) + err = InteractiveRequireLoginAndToS(ctx, client, getCluster()) } return err }, } -func InteractiveRequireLoginAndToS(ctx context.Context) error { +func InteractiveRequireLoginAndToS(ctx context.Context, fabric cliClient.FabricClient, addr string) error { var err error - if err = client.CheckLoginAndToS(ctx); err != nil { + if err = fabric.CheckLoginAndToS(ctx); err != nil { // Login interactively now; only do this for authorization-related errors if connect.CodeOf(err) == connect.CodeUnauthenticated { term.Debug("Server error:", err) @@ -400,16 +400,16 @@ func InteractiveRequireLoginAndToS(ctx context.Context) error { term.ResetWarnings() // clear any previous warnings so we don't show them again defer func() { track.Cmd(nil, "Login", P("reason", err)) }() - if err = login.InteractiveLogin(ctx, client, getCluster()); err != nil { + if err = login.InteractiveLogin(ctx, fabric, addr); err != nil { return err } // Reconnect with the new token - if client, err = cli.Connect(ctx, getCluster()); err != nil { + if fabric, err = cli.Connect(ctx, addr); err != nil { return err } - if err = client.CheckLoginAndToS(ctx); err == nil { // recheck (new token = new user) + if err = fabric.CheckLoginAndToS(ctx); err == nil { // recheck (new token = new user) return nil // success } } @@ -419,7 +419,7 @@ func InteractiveRequireLoginAndToS(ctx context.Context) error { term.Warn(cliClient.PrettyError(err)) defer func() { track.Cmd(nil, "Terms", P("reason", err)) }() - if err = login.InteractiveAgreeToS(ctx, client); err != nil { + if err = login.InteractiveAgreeToS(ctx, fabric); err != nil { return err // fatal } } From 84bd5483ad931a281d42a5250335816c8dfd5df6 Mon Sep 17 00:00:00 2001 From: Jordan Stephens Date: Tue, 19 Aug 2025 17:11:18 -0700 Subject: [PATCH 07/14] mv InteractiveRequireLoginAndToS into login package --- src/cmd/cli/command/commands.go | 40 +------------------------------- src/pkg/login/login.go | 41 +++++++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 39 deletions(-) diff --git a/src/cmd/cli/command/commands.go b/src/cmd/cli/command/commands.go index a59dfad69..4a4451a45 100644 --- a/src/cmd/cli/command/commands.go +++ b/src/cmd/cli/command/commands.go @@ -383,51 +383,13 @@ var RootCmd = &cobra.Command{ if nonInteractive { err = client.CheckLoginAndToS(ctx) } else { - err = InteractiveRequireLoginAndToS(ctx, client, getCluster()) + err = login.InteractiveRequireLoginAndToS(ctx, client, getCluster()) } return err }, } -func InteractiveRequireLoginAndToS(ctx context.Context, fabric cliClient.FabricClient, addr string) error { - var err error - if err = fabric.CheckLoginAndToS(ctx); err != nil { - // Login interactively now; only do this for authorization-related errors - if connect.CodeOf(err) == connect.CodeUnauthenticated { - term.Debug("Server error:", err) - term.Warn("Please log in to continue.") - term.ResetWarnings() // clear any previous warnings so we don't show them again - - defer func() { track.Cmd(nil, "Login", P("reason", err)) }() - if err = login.InteractiveLogin(ctx, fabric, addr); err != nil { - return err - } - - // Reconnect with the new token - if fabric, err = cli.Connect(ctx, addr); err != nil { - return err - } - - if err = fabric.CheckLoginAndToS(ctx); err == nil { // recheck (new token = new user) - return nil // success - } - } - - // Check if the user has agreed to the terms of service and show a prompt if needed - if connect.CodeOf(err) == connect.CodeFailedPrecondition { - term.Warn(cliClient.PrettyError(err)) - - defer func() { track.Cmd(nil, "Terms", P("reason", err)) }() - if err = login.InteractiveAgreeToS(ctx, fabric); err != nil { - return err // fatal - } - } - } - - return err -} - var loginCmd = &cobra.Command{ Use: "login", Args: cobra.NoArgs, diff --git a/src/pkg/login/login.go b/src/pkg/login/login.go index a09b529f4..39ba0cc5b 100644 --- a/src/pkg/login/login.go +++ b/src/pkg/login/login.go @@ -7,12 +7,15 @@ import ( "os" "github.com/DefangLabs/defang/src/pkg/auth" + "github.com/DefangLabs/defang/src/pkg/cli" "github.com/DefangLabs/defang/src/pkg/cli/client" "github.com/DefangLabs/defang/src/pkg/cluster" "github.com/DefangLabs/defang/src/pkg/dryrun" "github.com/DefangLabs/defang/src/pkg/github" "github.com/DefangLabs/defang/src/pkg/term" + "github.com/DefangLabs/defang/src/pkg/track" defangv1 "github.com/DefangLabs/defang/src/protos/io/defang/v1" + "github.com/bufbuild/connect-go" ) type LoginFlow = auth.LoginFlow @@ -107,3 +110,41 @@ func NonInteractiveGitHubLogin(ctx context.Context, client client.FabricClient, } return cluster.SaveAccessToken(fabric, resp.AccessToken) } + +func InteractiveRequireLoginAndToS(ctx context.Context, fabric client.FabricClient, addr string) error { + var err error + if err = fabric.CheckLoginAndToS(ctx); err != nil { + // Login interactively now; only do this for authorization-related errors + if connect.CodeOf(err) == connect.CodeUnauthenticated { + term.Debug("Server error:", err) + term.Warn("Please log in to continue.") + term.ResetWarnings() // clear any previous warnings so we don't show them again + + defer func() { track.Cmd(nil, "Login", P("reason", err)) }() + if err = InteractiveLogin(ctx, fabric, addr); err != nil { + return err + } + + // Reconnect with the new token + if fabric, err = cli.Connect(ctx, addr); err != nil { + return err + } + + if err = fabric.CheckLoginAndToS(ctx); err == nil { // recheck (new token = new user) + return nil // success + } + } + + // Check if the user has agreed to the terms of service and show a prompt if needed + if connect.CodeOf(err) == connect.CodeFailedPrecondition { + term.Warn(client.PrettyError(err)) + + defer func() { track.Cmd(nil, "Terms", P("reason", err)) }() + if err = InteractiveAgreeToS(ctx, fabric); err != nil { + return err // fatal + } + } + } + + return err +} From bd68bb98c7e468350e01ce2326d0ba1828578f83 Mon Sep 17 00:00:00 2001 From: Jordan Stephens Date: Tue, 19 Aug 2025 17:12:33 -0700 Subject: [PATCH 08/14] use login.InteractiveRequireLoginAndToS in MigrateFromHeroku --- src/pkg/setup/setup.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/pkg/setup/setup.go b/src/pkg/setup/setup.go index b19a3a75e..8d961cb4b 100644 --- a/src/pkg/setup/setup.go +++ b/src/pkg/setup/setup.go @@ -226,7 +226,8 @@ func beforeGenerate(directory string) { } func (s *SetupClient) MigrateFromHeroku(ctx context.Context) (SetupResult, error) { - if err := login.InteractiveLogin(ctx, s.Fabric, s.Cluster); err != nil { + err := login.InteractiveRequireLoginAndToS(ctx, s.Fabric, s.Cluster) + if err != nil { return SetupResult{}, err } From a96e112b928d239721ceef1fa244b1d7691155e8 Mon Sep 17 00:00:00 2001 From: Eric Liu Date: Wed, 20 Aug 2025 11:28:11 -0700 Subject: [PATCH 09/14] allow passing of access token in login --- src/cmd/cli/command/commands.go | 5 ++++- src/pkg/cluster/cluster.go | 10 ++++++++-- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/src/cmd/cli/command/commands.go b/src/cmd/cli/command/commands.go index 4a4451a45..fa559f40d 100644 --- a/src/cmd/cli/command/commands.go +++ b/src/cmd/cli/command/commands.go @@ -207,7 +207,8 @@ func SetupCommands(ctx context.Context, version string) { RootCmd.AddCommand(tokenCmd) // Login Command - loginCmd.Flags().Bool("training-opt-out", false, "Opt out of ML training (Pro users only)") + loginCmd.Flags().Bool("training-opt-out", false, "opt out of ML training (Pro users only)") + loginCmd.Flags().StringP("token", "t", "", "access token to use for authentication") // loginCmd.Flags().Bool("skip-prompt", false, "skip the login prompt if already logged in"); TODO: Implement this RootCmd.AddCommand(loginCmd) @@ -396,6 +397,8 @@ var loginCmd = &cobra.Command{ Short: "Authenticate to Defang", RunE: func(cmd *cobra.Command, args []string) error { trainingOptOut, _ := cmd.Flags().GetBool("training-opt-out") + token, _ := cmd.Flags().GetString("token") + pcluster.SetTemporaryAccessToken(token) if nonInteractive { if err := login.NonInteractiveGitHubLogin(cmd.Context(), client, getCluster()); err != nil { diff --git a/src/pkg/cluster/cluster.go b/src/pkg/cluster/cluster.go index 2fcdda107..a73261e54 100644 --- a/src/pkg/cluster/cluster.go +++ b/src/pkg/cluster/cluster.go @@ -15,8 +15,14 @@ import ( const DefaultCluster = "fabric-prod1.defang.dev" +var DefaultAccessToken = "" + var DefangFabric = pkg.Getenv("DEFANG_FABRIC", DefaultCluster) +func SetTemporaryAccessToken(accessToken string) { + DefaultAccessToken = accessToken +} + func SplitTenantHost(cluster string) (types.TenantName, string) { tenant := types.DEFAULT_TENANT parts := strings.SplitN(cluster, "@", 2) @@ -40,7 +46,7 @@ func GetTokenFile(fabric string) string { } func GetExistingToken(fabric string) string { - var accessToken = os.Getenv("DEFANG_ACCESS_TOKEN") + var accessToken = pkg.Getenv("DEFANG_ACCESS_TOKEN", DefaultAccessToken) if accessToken == "" { tokenFile := GetTokenFile(fabric) @@ -49,7 +55,7 @@ func GetExistingToken(fabric string) string { all, _ := os.ReadFile(tokenFile) accessToken = string(all) } else { - term.Debug("Using access token from env DEFANG_ACCESS_TOKEN") + term.Debug("Using access token from env DEFANG_ACCESS_TOKEN", DefaultAccessToken) } return accessToken From 75d42c2953451be427459e3eb5acbff0ac2d7ce1 Mon Sep 17 00:00:00 2001 From: Eric Liu Date: Wed, 20 Aug 2025 16:14:26 -0700 Subject: [PATCH 10/14] assign login token to global if set in arg --- src/cmd/cli/command/commands.go | 5 +---- src/pkg/cluster/cluster.go | 12 ++++++------ 2 files changed, 7 insertions(+), 10 deletions(-) diff --git a/src/cmd/cli/command/commands.go b/src/cmd/cli/command/commands.go index fa559f40d..dd4b42146 100644 --- a/src/cmd/cli/command/commands.go +++ b/src/cmd/cli/command/commands.go @@ -94,7 +94,6 @@ func Execute(ctx context.Context) error { if strings.Contains(err.Error(), "config") { printDefangHint("To manage sensitive service config, use:", "config") } - if strings.Contains(err.Error(), "maximum number of projects") { projectName := "" provider, err := newProvider(ctx, nil) @@ -208,7 +207,7 @@ func SetupCommands(ctx context.Context, version string) { // Login Command loginCmd.Flags().Bool("training-opt-out", false, "opt out of ML training (Pro users only)") - loginCmd.Flags().StringP("token", "t", "", "access token to use for authentication") + loginCmd.Flags().StringVar(&pcluster.DefaultAccessToken, "token", "", "access token to use for authentication") // loginCmd.Flags().Bool("skip-prompt", false, "skip the login prompt if already logged in"); TODO: Implement this RootCmd.AddCommand(loginCmd) @@ -397,8 +396,6 @@ var loginCmd = &cobra.Command{ Short: "Authenticate to Defang", RunE: func(cmd *cobra.Command, args []string) error { trainingOptOut, _ := cmd.Flags().GetBool("training-opt-out") - token, _ := cmd.Flags().GetString("token") - pcluster.SetTemporaryAccessToken(token) if nonInteractive { if err := login.NonInteractiveGitHubLogin(cmd.Context(), client, getCluster()); err != nil { diff --git a/src/pkg/cluster/cluster.go b/src/pkg/cluster/cluster.go index a73261e54..6fa1eb0dc 100644 --- a/src/pkg/cluster/cluster.go +++ b/src/pkg/cluster/cluster.go @@ -19,10 +19,6 @@ var DefaultAccessToken = "" var DefangFabric = pkg.Getenv("DEFANG_FABRIC", DefaultCluster) -func SetTemporaryAccessToken(accessToken string) { - DefaultAccessToken = accessToken -} - func SplitTenantHost(cluster string) (types.TenantName, string) { tenant := types.DEFAULT_TENANT parts := strings.SplitN(cluster, "@", 2) @@ -46,7 +42,11 @@ func GetTokenFile(fabric string) string { } func GetExistingToken(fabric string) string { - var accessToken = pkg.Getenv("DEFANG_ACCESS_TOKEN", DefaultAccessToken) + if DefaultAccessToken != "" { + return DefaultAccessToken + } + + var accessToken = os.Getenv("DEFANG_ACCESS_TOKEN") if accessToken == "" { tokenFile := GetTokenFile(fabric) @@ -55,7 +55,7 @@ func GetExistingToken(fabric string) string { all, _ := os.ReadFile(tokenFile) accessToken = string(all) } else { - term.Debug("Using access token from env DEFANG_ACCESS_TOKEN", DefaultAccessToken) + term.Debug("Using access token from env DEFANG_ACCESS_TOKEN", accessToken) } return accessToken From 81fe375ef9de851dc8741d7bcd2b8034e0fc854c Mon Sep 17 00:00:00 2001 From: Eric Liu Date: Thu, 21 Aug 2025 09:44:00 -0700 Subject: [PATCH 11/14] add token handling to login --- src/cmd/cli/command/commands.go | 5 +++-- src/pkg/cluster/cluster.go | 6 ------ src/pkg/login/login.go | 17 ++++++++++------- src/pkg/login/login_test.go | 5 +++-- 4 files changed, 16 insertions(+), 17 deletions(-) diff --git a/src/cmd/cli/command/commands.go b/src/cmd/cli/command/commands.go index dd4b42146..9eb0c223e 100644 --- a/src/cmd/cli/command/commands.go +++ b/src/cmd/cli/command/commands.go @@ -207,7 +207,7 @@ func SetupCommands(ctx context.Context, version string) { // Login Command loginCmd.Flags().Bool("training-opt-out", false, "opt out of ML training (Pro users only)") - loginCmd.Flags().StringVar(&pcluster.DefaultAccessToken, "token", "", "access token to use for authentication") + loginCmd.Flags().String("token", "", "access token to use for authentication") // loginCmd.Flags().Bool("skip-prompt", false, "skip the login prompt if already logged in"); TODO: Implement this RootCmd.AddCommand(loginCmd) @@ -396,9 +396,10 @@ var loginCmd = &cobra.Command{ Short: "Authenticate to Defang", RunE: func(cmd *cobra.Command, args []string) error { trainingOptOut, _ := cmd.Flags().GetBool("training-opt-out") + token, _ := cmd.Flags().GetString("token") if nonInteractive { - if err := login.NonInteractiveGitHubLogin(cmd.Context(), client, getCluster()); err != nil { + if err := login.NonInteractiveLogin(cmd.Context(), client, getCluster(), token); err != nil { return err } } else { diff --git a/src/pkg/cluster/cluster.go b/src/pkg/cluster/cluster.go index 6fa1eb0dc..8a08aa3db 100644 --- a/src/pkg/cluster/cluster.go +++ b/src/pkg/cluster/cluster.go @@ -15,8 +15,6 @@ import ( const DefaultCluster = "fabric-prod1.defang.dev" -var DefaultAccessToken = "" - var DefangFabric = pkg.Getenv("DEFANG_FABRIC", DefaultCluster) func SplitTenantHost(cluster string) (types.TenantName, string) { @@ -42,10 +40,6 @@ func GetTokenFile(fabric string) string { } func GetExistingToken(fabric string) string { - if DefaultAccessToken != "" { - return DefaultAccessToken - } - var accessToken = os.Getenv("DEFANG_ACCESS_TOKEN") if accessToken == "" { diff --git a/src/pkg/login/login.go b/src/pkg/login/login.go index 39ba0cc5b..0889ec6ae 100644 --- a/src/pkg/login/login.go +++ b/src/pkg/login/login.go @@ -94,15 +94,18 @@ func interactiveLogin(ctx context.Context, client client.FabricClient, fabric st return nil } -func NonInteractiveGitHubLogin(ctx context.Context, client client.FabricClient, fabric string) error { - term.Debug("Non-interactive login using GitHub Actions id-token") - idToken, err := github.GetIdToken(ctx) - if err != nil { - return fmt.Errorf("non-interactive login failed: %w", err) +func NonInteractiveLogin(ctx context.Context, client client.FabricClient, fabric, token string) error { + if token == "" { + term.Debug("Non-interactive login using GitHub Actions id-token") + var err error + token, err = github.GetIdToken(ctx) + if err != nil { + return fmt.Errorf("non-interactive login failed: %w", err) + } + term.Debug("Got GitHub Actions id-token") } - term.Debug("Got GitHub Actions id-token") resp, err := client.Token(ctx, &defangv1.TokenRequest{ - Assertion: idToken, + Assertion: token, Scope: []string{"admin", "read", "delete", "tail"}, }) if err != nil { diff --git a/src/pkg/login/login_test.go b/src/pkg/login/login_test.go index 092db3919..6490f8d09 100644 --- a/src/pkg/login/login_test.go +++ b/src/pkg/login/login_test.go @@ -108,6 +108,7 @@ func TestNonInteractiveLogin(t *testing.T) { ctx := context.Background() mockClient := &MockForNonInteractiveLogin{} fabric := "test.defang.dev" + token := "" t.Run("Expect accessToken to be stored when NonInteractiveLogin() succeeds", func(t *testing.T) { requestUrl := os.Getenv("ACTIONS_ID_TOKEN_REQUEST_URL") @@ -122,7 +123,7 @@ func TestNonInteractiveLogin(t *testing.T) { t.Cleanup(func() { client.StateDir = prevStateDir }) - err := NonInteractiveGitHubLogin(ctx, mockClient, fabric) + err := NonInteractiveLogin(ctx, mockClient, fabric, token) if err != nil { t.Fatalf("expected no error, got %v", err) } @@ -139,7 +140,7 @@ func TestNonInteractiveLogin(t *testing.T) { t.Run("Expect error when NonInteractiveLogin() fails in the case that GitHub Actions info is not set", func(t *testing.T) { - err := NonInteractiveGitHubLogin(ctx, mockClient, fabric) + err := NonInteractiveLogin(ctx, mockClient, fabric, token) if err != nil && err.Error() != "non-interactive login failed: ACTIONS_ID_TOKEN_REQUEST_URL or ACTIONS_ID_TOKEN_REQUEST_TOKEN not set" { t.Fatalf("expected no error, got %v", err) From 0ef762c722de5968f12a595c14756099133036f3 Mon Sep 17 00:00:00 2001 From: Eric Liu Date: Fri, 22 Aug 2025 10:27:11 -0700 Subject: [PATCH 12/14] signature update --- src/pkg/login/login.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pkg/login/login.go b/src/pkg/login/login.go index 0889ec6ae..2a5aaaa8b 100644 --- a/src/pkg/login/login.go +++ b/src/pkg/login/login.go @@ -94,7 +94,7 @@ func interactiveLogin(ctx context.Context, client client.FabricClient, fabric st return nil } -func NonInteractiveLogin(ctx context.Context, client client.FabricClient, fabric, token string) error { +func NonInteractiveLogin(ctx context.Context, client client.FabricClient, fabric string, token string) error { if token == "" { term.Debug("Non-interactive login using GitHub Actions id-token") var err error From ed4850a2bfe6f42b983fc939dd4e5be08e14d64c Mon Sep 17 00:00:00 2001 From: Eric Liu Date: Mon, 8 Sep 2025 14:26:14 -0700 Subject: [PATCH 13/14] support for using auth server token exchange --- src/pkg/auth/auth.go | 10 ++++++++ src/pkg/auth/client.go | 43 +++++++++++++++++++++++++-------- src/pkg/auth/client_test.go | 48 +++++++++++++++++++++++++++++++++++++ src/pkg/login/login.go | 11 ++++----- 4 files changed, 95 insertions(+), 17 deletions(-) diff --git a/src/pkg/auth/auth.go b/src/pkg/auth/auth.go index 6f951b950..f5a8498b2 100644 --- a/src/pkg/auth/auth.go +++ b/src/pkg/auth/auth.go @@ -236,3 +236,13 @@ func ExchangeCodeForToken(ctx context.Context, code AuthCodeFlow, tenant types.T } return token.AccessToken, nil } + +func ExchangeJWTForToken(ctx context.Context, jwt string) (string, error) { + term.Debugf("Generating token for jwt %q", jwt) + + token, err := openAuthClient.ExchangeJWT(jwt) // TODO: scopes, TTL + if err != nil { + return "", err + } + return token.AccessToken, nil +} diff --git a/src/pkg/auth/client.go b/src/pkg/auth/client.go index 5efa511d1..fa53b07ba 100644 --- a/src/pkg/auth/client.go +++ b/src/pkg/auth/client.go @@ -25,6 +25,7 @@ const ( var ( ErrInvalidAccessToken = errors.New("invalid access token") ErrInvalidAuthorizationCode = errors.New("invalid authorization code") + ErrInvalidJWT = errors.New("invalid JWT") ErrInvalidRefreshToken = errors.New("invalid refresh token") ) @@ -124,6 +125,10 @@ type Client interface { * Exchange the code for access and refresh tokens. */ Exchange(code string, redirectURI string, verifier string) (*ExchangeSuccess, error) + /** + * Exchange jwt for access and refresh tokens. + */ + ExchangeJWT(jwt string) (*ExchangeSuccess, error) /** * Refreshes the tokens if they have expired. This is used in an SPA app to maintain the * session, without logging the user out. @@ -207,21 +212,14 @@ func (c client) callToken(body url.Values) (*Tokens, error) { } /** - * Exchange the code for access and refresh tokens. + * Helper function to exchange tokens with common error handling. */ -func (c client) Exchange(code string, redirectURI string, verifier string) (*ExchangeSuccess, error) { - body := url.Values{ - "client_id": {c.clientID}, - "code_verifier": {verifier}, - "code": {code}, - "grant_type": {"authorization_code"}, - "redirect_uri": {redirectURI}, - } +func (c client) exchangeForTokens(body url.Values, oauthErrorType error) (*ExchangeSuccess, error) { tokens, err := c.callToken(body) if err != nil { var oauthError *OAuthError if errors.As(err, &oauthError) { - return nil, fmt.Errorf("%w: %w", ErrInvalidAuthorizationCode, err) + return nil, fmt.Errorf("%w: %w", oauthErrorType, err) } return nil, fmt.Errorf("token exchange failed: %w", err) } @@ -231,6 +229,31 @@ func (c client) Exchange(code string, redirectURI string, verifier string) (*Exc }, nil } +/** + * Exchange the code for access and refresh tokens. + */ +func (c client) Exchange(code string, redirectURI string, verifier string) (*ExchangeSuccess, error) { + body := url.Values{ + "client_id": {c.clientID}, + "code_verifier": {verifier}, + "code": {code}, + "grant_type": {"authorization_code"}, + "redirect_uri": {redirectURI}, + } + return c.exchangeForTokens(body, ErrInvalidAuthorizationCode) +} + +/** + * Exchange the jwt for access and refresh tokens. + */ +func (c client) ExchangeJWT(jwt string) (*ExchangeSuccess, error) { + body := url.Values{ + "grant_type": {"urn:ietf:params:oauth:grant-type:jwt-bearer"}, + "assertion": {jwt}, + } + return c.exchangeForTokens(body, ErrInvalidJWT) +} + /** * Refreshes the tokens if they have expired. */ diff --git a/src/pkg/auth/client_test.go b/src/pkg/auth/client_test.go index 3d5f2e392..888660b16 100644 --- a/src/pkg/auth/client_test.go +++ b/src/pkg/auth/client_test.go @@ -63,6 +63,54 @@ func TestExchange(t *testing.T) { }) } +func TestExchangeJWT(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/token": + if r.Method != http.MethodPost { + t.Errorf("Expected POST method, got %s", r.Method) + } + if expected, got := "urn:ietf:params:oauth:grant-type:jwt-bearer", r.PostFormValue("grant_type"); expected != got { + t.Errorf("Expected grant_type %s, got: %s", expected, got) + } + + jwt := r.PostFormValue("assertion") + if jwt == "valid-jwt" { + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{"access_token":"jwt-access-token","refresh_token":"jwt-refresh-token"}`)) + } else { + w.Write([]byte(`{"error":"invalid_request","error_description":"Invalid request"}`)) + } + default: + http.Error(w, "Not Found", http.StatusNotFound) + } + })) + t.Cleanup(server.CloseClientConnections) + + client := NewClient("defang-cli", server.URL) + + t.Run("success", func(t *testing.T) { + result, err := client.ExchangeJWT("valid-jwt") + if err != nil { + t.Fatalf("Expected no error, got: %v", err) + } + if result.AccessToken != "jwt-access-token" { + t.Errorf("Expected access token 'jwt-access-token', got: %s", result.AccessToken) + } + if result.RefreshToken != "jwt-refresh-token" { + t.Errorf("Expected refresh token 'jwt-refresh-token', got: %s", result.RefreshToken) + } + }) + + t.Run("invalid jwt", func(t *testing.T) { + _, err := client.ExchangeJWT("invalid-jwt") + const expected = "invalid JWT: Invalid request" + if err.Error() != expected { + t.Fatalf("Expected error %q, got: %v", expected, err) + } + }) +} + func TestAuthorizeExchange(t *testing.T) { if testing.Short() { t.Skip("skipping browser test in short mode.") diff --git a/src/pkg/login/login.go b/src/pkg/login/login.go index 2a5aaaa8b..afc04faac 100644 --- a/src/pkg/login/login.go +++ b/src/pkg/login/login.go @@ -14,7 +14,6 @@ import ( "github.com/DefangLabs/defang/src/pkg/github" "github.com/DefangLabs/defang/src/pkg/term" "github.com/DefangLabs/defang/src/pkg/track" - defangv1 "github.com/DefangLabs/defang/src/protos/io/defang/v1" "github.com/bufbuild/connect-go" ) @@ -104,14 +103,12 @@ func NonInteractiveLogin(ctx context.Context, client client.FabricClient, fabric } term.Debug("Got GitHub Actions id-token") } - resp, err := client.Token(ctx, &defangv1.TokenRequest{ - Assertion: token, - Scope: []string{"admin", "read", "delete", "tail"}, - }) + + accessToken, err := auth.ExchangeJWTForToken(ctx, token) if err != nil { - return err + return fmt.Errorf("non-interactive login failed: %w", err) } - return cluster.SaveAccessToken(fabric, resp.AccessToken) + return cluster.SaveAccessToken(fabric, accessToken) } func InteractiveRequireLoginAndToS(ctx context.Context, fabric client.FabricClient, addr string) error { From a51900a3228419a497025dec64840c8198483154 Mon Sep 17 00:00:00 2001 From: Eric Liu Date: Tue, 9 Sep 2025 16:20:45 -0700 Subject: [PATCH 14/14] review updates --- src/pkg/auth/client.go | 36 +++++++++++++++++++++++++++--------- src/pkg/cluster/cluster.go | 2 +- 2 files changed, 28 insertions(+), 10 deletions(-) diff --git a/src/pkg/auth/client.go b/src/pkg/auth/client.go index fa53b07ba..b2a08bf5f 100644 --- a/src/pkg/auth/client.go +++ b/src/pkg/auth/client.go @@ -214,14 +214,10 @@ func (c client) callToken(body url.Values) (*Tokens, error) { /** * Helper function to exchange tokens with common error handling. */ -func (c client) exchangeForTokens(body url.Values, oauthErrorType error) (*ExchangeSuccess, error) { +func (c client) exchangeForTokens(body url.Values) (*ExchangeSuccess, error) { tokens, err := c.callToken(body) if err != nil { - var oauthError *OAuthError - if errors.As(err, &oauthError) { - return nil, fmt.Errorf("%w: %w", oauthErrorType, err) - } - return nil, fmt.Errorf("token exchange failed: %w", err) + return nil, err } return &ExchangeSuccess{ @@ -240,18 +236,40 @@ func (c client) Exchange(code string, redirectURI string, verifier string) (*Exc "grant_type": {"authorization_code"}, "redirect_uri": {redirectURI}, } - return c.exchangeForTokens(body, ErrInvalidAuthorizationCode) + + result, err := c.exchangeForTokens(body) + if err != nil { + var oauthError *OAuthError + if errors.As(err, &oauthError) { + return nil, fmt.Errorf("%w: %w", ErrInvalidAuthorizationCode, err) + } + + return nil, fmt.Errorf("token exchange failed: %w", err) + } + + return result, nil } /** - * Exchange the jwt for access and refresh tokens. + * Exchange the JWT for access and refresh tokens. */ func (c client) ExchangeJWT(jwt string) (*ExchangeSuccess, error) { body := url.Values{ "grant_type": {"urn:ietf:params:oauth:grant-type:jwt-bearer"}, "assertion": {jwt}, } - return c.exchangeForTokens(body, ErrInvalidJWT) + result, err := c.exchangeForTokens(body) + + if err != nil { + var oauthError *OAuthError + if errors.As(err, &oauthError) { + return nil, fmt.Errorf("%w: %w", ErrInvalidJWT, err) + } + + return nil, fmt.Errorf("token exchange failed: %w", err) + } + + return result, nil } /** diff --git a/src/pkg/cluster/cluster.go b/src/pkg/cluster/cluster.go index 8a08aa3db..2fcdda107 100644 --- a/src/pkg/cluster/cluster.go +++ b/src/pkg/cluster/cluster.go @@ -49,7 +49,7 @@ func GetExistingToken(fabric string) string { all, _ := os.ReadFile(tokenFile) accessToken = string(all) } else { - term.Debug("Using access token from env DEFANG_ACCESS_TOKEN", accessToken) + term.Debug("Using access token from env DEFANG_ACCESS_TOKEN") } return accessToken