mirror of
https://github.com/larksuite/cli.git
synced 2026-08-03 08:32:46 +08:00
refactor(auth): improve existing app registration flow
This commit is contained in:
@@ -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).
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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"))
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user