diff --git a/cmd/config/init_auth_method_test.go b/cmd/config/init_auth_method_test.go index 5e44bf638..33f185640 100644 --- a/cmd/config/init_auth_method_test.go +++ b/cmd/config/init_auth_method_test.go @@ -66,6 +66,18 @@ func TestResolveRegisterAuthMethod(t *testing.T) { } } +func TestExistingAppRequiresSecret(t *testing.T) { + if !existingAppRequiresSecret(core.AuthMethodClientSecret) { + t.Error("client_secret existing app should require App Secret") + } + if existingAppRequiresSecret("") != true { + t.Error("default existing app should require App Secret") + } + if existingAppRequiresSecret(core.AuthMethodPrivateKeyJWT) { + t.Error("private_key_jwt existing app should not require App Secret") + } +} + // TestValidatePKJWTKeyBinding covers the guard that rejects a registration // resolving to private_key_jwt with no signing key bound (e.g. an existing // secret-based app was selected on the confirm page). diff --git a/cmd/config/init_interactive.go b/cmd/config/init_interactive.go index 0d9433bda..21190ddde 100644 --- a/cmd/config/init_interactive.go +++ b/cmd/config/init_interactive.go @@ -58,14 +58,18 @@ func runInteractiveConfigInit(ctx context.Context, f *cmdutil.Factory, authMetho } if mode == "existing" { - return runExistingAppForm(f, msg) + return runExistingAppForm(ctx, f, authMethodFlag, msg) } return runCreateAppFlow(ctx, f, "", authMethodFlag, msg, "") } +func existingAppRequiresSecret(requestedAuthMethod string) bool { + return requestedAuthMethod != core.AuthMethodPrivateKeyJWT +} + // runExistingAppForm shows a huh form for manually entering App ID / App Secret / Brand. -func runExistingAppForm(f *cmdutil.Factory, msg *initMsg) (*configInitResult, error) { +func runExistingAppForm(ctx context.Context, f *cmdutil.Factory, requestedAuthMethod string, msg *initMsg) (*configInitResult, error) { // Load existing config for defaults existing, _ := core.LoadMultiAppConfig() var firstApp *core.AppConfig @@ -99,19 +103,31 @@ func runExistingAppForm(f *cmdutil.Factory, msg *initMsg) (*configInitResult, er brand = string(firstApp.Brand) } - form := huh.NewForm( - huh.NewGroup( - appIDInput, - appSecretInput, - huh.NewSelect[string](). - Title(msg.Platform). - Options( - huh.NewOption(msg.Feishu, "feishu"), - huh.NewOption("Lark", "lark"), - ). - Value(&brand), - ), - ).WithTheme(cmdutil.ThemeFeishu()) + brandSelect := huh.NewSelect[string](). + Title(msg.Platform). + Options( + huh.NewOption(msg.Feishu, "feishu"), + huh.NewOption("Lark", "lark"), + ). + Value(&brand) + + var form *huh.Form + if existingAppRequiresSecret(requestedAuthMethod) { + form = huh.NewForm( + huh.NewGroup( + appIDInput, + appSecretInput, + brandSelect, + ), + ).WithTheme(cmdutil.ThemeFeishu()) + } else { + form = huh.NewForm( + huh.NewGroup( + appIDInput, + brandSelect, + ), + ).WithTheme(cmdutil.ThemeFeishu()) + } if err := form.Run(); err != nil { if err == huh.ErrUserAborted { @@ -124,6 +140,13 @@ func runExistingAppForm(f *cmdutil.Factory, msg *initMsg) (*configInitResult, er if appID == "" && firstApp != nil { appID = firstApp.AppId } + if !existingAppRequiresSecret(requestedAuthMethod) { + if appID == "" { + return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "App ID cannot be empty"). + WithParam("--app-id") + } + return runCreateAppFlow(ctx, f, parseBrand(brand), core.AuthMethodPrivateKeyJWT, msg, appID) + } if appSecret == "" && firstApp != nil && !firstApp.AppSecret.IsZero() { // Keep existing secret - caller will handle return &configInitResult{ @@ -260,7 +283,7 @@ func runCreateAppFlow(ctx context.Context, f *cmdutil.Factory, brandOverride cor } // Step 2: Build and display verification URL + QR code - verificationURL := larkauth.BuildVerificationURL(authResp.VerificationUriComplete, build.Version) + verificationURL := larkauth.BuildVerificationURL(authResp.VerificationUriComplete, build.Version, restoreAppID) // Branch on TTY: human-friendly copy in interactive terminals, // preserve original copy for AI / non-interactive callers. @@ -305,7 +328,7 @@ func runCreateAppFlow(ctx context.Context, f *cmdutil.Factory, brandOverride cor } if result.ClientID == "" { - return nil, errs.NewConfigError(errs.SubtypeInvalidClient, "app registration succeeded but missing client_id") + return nil, errs.NewConfigError(errs.SubtypeInvalidClient, "app registration succeeded but missing app_id") } if finalMethod != core.AuthMethodPrivateKeyJWT && result.ClientSecret == "" { return nil, errs.NewConfigError(errs.SubtypeInvalidClient, "app registration succeeded but missing client_secret") diff --git a/internal/auth/app_registration.go b/internal/auth/app_registration.go index 9d4bf2a47..76b6f4a24 100644 --- a/internal/auth/app_registration.go +++ b/internal/auth/app_registration.go @@ -196,7 +196,7 @@ func RequestAppRegistration(httpClient *http.Client, brand core.LarkBrand, opts base = ep.Open + "/page/launcher" } // The server may return verification_uri with its own query (e.g. - // client_id when registering against an existing app), so join with + // app_id when registering against an existing app), so join with // the same ?/& logic as BuildVerificationURL. sep := "?" if strings.Contains(base, "?") { @@ -236,14 +236,42 @@ func parseAuthMethods(v interface{}) []string { } // BuildVerificationURL appends CLI tracking parameters to the verification URL. -func BuildVerificationURL(baseURL, cliVersion string) string { +// When targetAppID is non-empty, it is also included so the launcher can lock +// authorization to that existing app. +func BuildVerificationURL(baseURL, cliVersion string, targetAppID ...string) string { + u, err := url.Parse(baseURL) + if err != nil { + return appendVerificationURLFallback(baseURL, cliVersion, targetAppID...) + } + q := u.Query() + if q.Get("lpv") == "" { + q.Set("lpv", cliVersion) + } + if q.Get("ocv") == "" { + q.Set("ocv", cliVersion) + } + if q.Get("from") == "" { + q.Set("from", "cli") + } + if len(targetAppID) > 0 && targetAppID[0] != "" && q.Get("app_id") == "" { + q.Set("app_id", targetAppID[0]) + } + u.RawQuery = q.Encode() + return u.String() +} + +func appendVerificationURLFallback(baseURL, cliVersion string, targetAppID ...string) string { sep := "&" if !strings.Contains(baseURL, "?") { sep = "?" } - return baseURL + sep + "lpv=" + url.QueryEscape(cliVersion) + + out := baseURL + sep + "lpv=" + url.QueryEscape(cliVersion) + "&ocv=" + url.QueryEscape(cliVersion) + "&from=cli" + if len(targetAppID) > 0 && targetAppID[0] != "" && !strings.Contains(baseURL, "app_id=") { + out += "&app_id=" + url.QueryEscape(targetAppID[0]) + } + return out } // PollAppRegistration polls the app registration endpoint until the app is created or the flow times out. @@ -310,7 +338,7 @@ func PollAppRegistration(ctx context.Context, httpClient *http.Client, brand cor errStr := getStr(data, "error") - // Success: client_id present + // Success: app id present (server field is named client_id). if errStr == "" && getStr(data, "client_id") != "" { result := &AppRegistrationResult{ ClientID: getStr(data, "client_id"), diff --git a/internal/auth/app_registration_test.go b/internal/auth/app_registration_test.go index 9c57dabdd..20bb3a829 100644 --- a/internal/auth/app_registration_test.go +++ b/internal/auth/app_registration_test.go @@ -19,10 +19,14 @@ import ( func Test_BuildVerificationURL(t *testing.T) { t.Run("URL不含问号则添加?分隔符", func(t *testing.T) { result := BuildVerificationURL("https://example.com/verify", "1.0.0") + got, err := url.Parse(result) + if err != nil { + t.Fatal(err) + } convey.Convey("should add ? separator", t, func() { - convey.So(result, convey.ShouldContainSubstring, "?lpv=1.0.0") - convey.So(result, convey.ShouldContainSubstring, "&ocv=1.0.0") - convey.So(result, convey.ShouldContainSubstring, "&from=cli") + convey.So(got.Query().Get("lpv"), convey.ShouldEqual, "1.0.0") + convey.So(got.Query().Get("ocv"), convey.ShouldEqual, "1.0.0") + convey.So(got.Query().Get("from"), convey.ShouldEqual, "cli") convey.So(result, convey.ShouldStartWith, "https://example.com/verify?") }) }) @@ -36,6 +40,30 @@ func Test_BuildVerificationURL(t *testing.T) { convey.So(result, convey.ShouldNotContainSubstring, "?lpv=") }) }) + + t.Run("指定已有应用时添加app_id", func(t *testing.T) { + result := BuildVerificationURL("https://example.com/verify?user_code=abc", "2.0.0", "cli_existing") + got, err := url.Parse(result) + if err != nil { + t.Fatal(err) + } + convey.Convey("should include target app_id", t, func() { + convey.So(got.Query().Get("app_id"), convey.ShouldEqual, "cli_existing") + convey.So(got.Query().Get("client_id"), convey.ShouldEqual, "") + convey.So(got.Query().Get("lpv"), convey.ShouldEqual, "2.0.0") + }) + }) + + t.Run("服务端已返回app_id时不覆盖", func(t *testing.T) { + result := BuildVerificationURL("https://example.com/verify?app_id=cli_server&user_code=abc", "2.0.0", "cli_existing") + got, err := url.Parse(result) + if err != nil { + t.Fatal(err) + } + convey.Convey("should keep server app_id", t, func() { + convey.So(got.Query().Get("app_id"), convey.ShouldEqual, "cli_server") + }) + }) } // captureClient returns an http.Client that records the last request's form body @@ -169,8 +197,8 @@ func TestRequestAppRegistration_VerificationURICompleteFallback(t *testing.T) { }, { name: "verification_uri with existing query", - resp: `{"device_code":"dc","user_code":"uc","verification_uri":"https://example/verify?client_id=cli_x","expires_in":300,"interval":5}`, - want: "https://example/verify?client_id=cli_x&user_code=uc", + resp: `{"device_code":"dc","user_code":"uc","verification_uri":"https://example/verify?app_id=cli_x","expires_in":300,"interval":5}`, + want: "https://example/verify?app_id=cli_x&user_code=uc", }, } for _, tc := range cases { @@ -218,3 +246,26 @@ func TestRequestAppRegistration_BeginPrivateKeyJWT(t *testing.T) { t.Errorf("auth_attestation = %q", body.Get("auth_attestation")) } } + +func TestRequestAppRegistration_BeginPrivateKeyJWTExistingAppID(t *testing.T) { + var body url.Values + hc := captureClient(&body, beginRespJSON) + + opts := AppRegistrationBeginOptions{ + AuthMethod: core.AuthMethodPrivateKeyJWT, + AuthAttestation: "header.claims.sig", + RestoreAppID: "cli_existing", + } + if _, err := RequestAppRegistration(hc, core.BrandFeishu, opts, nil); err != nil { + t.Fatal(err) + } + if body.Get("auth_method") != "private_key_jwt" { + t.Errorf("auth_method = %q, want private_key_jwt", body.Get("auth_method")) + } + if body.Get("auth_attestation") != "header.claims.sig" { + t.Errorf("auth_attestation = %q", body.Get("auth_attestation")) + } + if body.Get("app_id") != "cli_existing" { + t.Errorf("app_id = %q, want cli_existing", body.Get("app_id")) + } +}