mirror of
https://github.com/larksuite/cli.git
synced 2026-07-07 17:45:15 +08:00
- ResolveConfigFromMulti: reject unknown authMethod and require keyRef for private_key_jwt at resolution time (fail-fast vs. silent client_secret degrade or later token-signing failure) - init_interactive: comment why an empty SupportedAuthMethods intentionally allows the requested private_key_jwt (older-server back-compat, mirrors resolveFinalAuthMethod) - tests: invalid authMethod & missing keyRef resolution errors; empty SupportedAuthMethods init parse; explicit empty-slice resolveFinalAuthMethod
385 lines
13 KiB
Go
385 lines
13 KiB
Go
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package config
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"slices"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/charmbracelet/huh"
|
|
"github.com/larksuite/cli/internal/build"
|
|
qrcode "github.com/skip2/go-qrcode"
|
|
|
|
"github.com/larksuite/cli/errs"
|
|
"github.com/larksuite/cli/extension/keysigner"
|
|
larkauth "github.com/larksuite/cli/internal/auth"
|
|
"github.com/larksuite/cli/internal/auth/jwt"
|
|
"github.com/larksuite/cli/internal/cmdutil"
|
|
"github.com/larksuite/cli/internal/core"
|
|
"github.com/larksuite/cli/internal/output"
|
|
"github.com/larksuite/cli/internal/transport"
|
|
)
|
|
|
|
// configInitResult holds the result of the interactive config init flow.
|
|
type configInitResult struct {
|
|
Mode string // "create" or "existing"
|
|
Brand core.LarkBrand
|
|
AppID string
|
|
AppSecret string
|
|
AuthMethod string // "" == client_secret; core.AuthMethodPrivateKeyJWT
|
|
KeyLabel string // TEE key handle when AuthMethod == private_key_jwt
|
|
}
|
|
|
|
// runInteractiveConfigInit shows an interactive TUI for config init.
|
|
func runInteractiveConfigInit(ctx context.Context, f *cmdutil.Factory, authMethodFlag string, msg *initMsg) (*configInitResult, error) {
|
|
// Phase 1: Choose mode
|
|
var mode string
|
|
form1 := huh.NewForm(
|
|
huh.NewGroup(
|
|
huh.NewSelect[string]().
|
|
Title(msg.SelectAction).
|
|
Options(
|
|
huh.NewOption(msg.CreateNewApp, "create"),
|
|
huh.NewOption(msg.ConfigExistingApp, "existing"),
|
|
).
|
|
Value(&mode),
|
|
),
|
|
).WithTheme(cmdutil.ThemeFeishu())
|
|
|
|
if err := form1.Run(); err != nil {
|
|
if err == huh.ErrUserAborted {
|
|
return nil, output.ErrBare(1)
|
|
}
|
|
return nil, err
|
|
}
|
|
|
|
if mode == "existing" {
|
|
return runExistingAppForm(f, msg)
|
|
}
|
|
|
|
return runCreateAppFlow(ctx, f, "", authMethodFlag, msg)
|
|
}
|
|
|
|
// runExistingAppForm shows a huh form for manually entering App ID / App Secret / Brand.
|
|
func runExistingAppForm(f *cmdutil.Factory, msg *initMsg) (*configInitResult, error) {
|
|
// Load existing config for defaults
|
|
existing, _ := core.LoadMultiAppConfig()
|
|
var firstApp *core.AppConfig
|
|
if existing != nil {
|
|
firstApp = existing.CurrentAppConfig("")
|
|
}
|
|
|
|
var appID, appSecret, brand string
|
|
|
|
appIDInput := huh.NewInput().
|
|
Title("App ID").
|
|
Value(&appID)
|
|
if firstApp != nil && firstApp.AppId != "" {
|
|
appIDInput = appIDInput.Placeholder(firstApp.AppId)
|
|
} else {
|
|
appIDInput = appIDInput.Placeholder("cli_xxxx")
|
|
}
|
|
|
|
appSecretInput := huh.NewInput().
|
|
Title("App Secret").
|
|
EchoMode(huh.EchoModePassword).
|
|
Value(&appSecret)
|
|
if firstApp != nil && !firstApp.AppSecret.IsZero() {
|
|
appSecretInput = appSecretInput.Placeholder("****")
|
|
} else {
|
|
appSecretInput = appSecretInput.Placeholder("xxxx")
|
|
}
|
|
|
|
brand = "feishu"
|
|
if firstApp != nil && firstApp.Brand != "" {
|
|
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())
|
|
|
|
if err := form.Run(); err != nil {
|
|
if err == huh.ErrUserAborted {
|
|
return nil, output.ErrBare(1)
|
|
}
|
|
return nil, err
|
|
}
|
|
|
|
// Resolve defaults
|
|
if appID == "" && firstApp != nil {
|
|
appID = firstApp.AppId
|
|
}
|
|
if appSecret == "" && firstApp != nil && !firstApp.AppSecret.IsZero() {
|
|
// Keep existing secret - caller will handle
|
|
return &configInitResult{
|
|
Mode: "existing",
|
|
Brand: parseBrand(brand),
|
|
AppID: appID,
|
|
}, nil
|
|
}
|
|
|
|
switch {
|
|
case appID == "" && appSecret == "":
|
|
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "App ID and App Secret cannot be empty").
|
|
WithParam("--app-id")
|
|
case appID == "":
|
|
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "App ID cannot be empty").
|
|
WithParam("--app-id")
|
|
case appSecret == "":
|
|
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "App Secret cannot be empty").
|
|
WithParam("--app-secret")
|
|
}
|
|
|
|
return &configInitResult{
|
|
Mode: "existing",
|
|
Brand: parseBrand(brand),
|
|
AppID: appID,
|
|
AppSecret: appSecret,
|
|
}, nil
|
|
}
|
|
|
|
// resolveRegisterAuthMethod decides the auth method for a new-app registration.
|
|
// An explicit --auth-method flag wins; otherwise, on an interactive terminal with
|
|
// a TEE signer available, the user is prompted; the default is client_secret.
|
|
func resolveRegisterAuthMethod(f *cmdutil.Factory, flag string) (string, error) {
|
|
signerAvailable := keysigner.Active() != nil
|
|
switch flag {
|
|
case core.AuthMethodPrivateKeyJWT:
|
|
if !signerAvailable {
|
|
return "", errs.NewConfigError(errs.SubtypeInvalidClient,
|
|
"--auth-method private_key_jwt requires a TEE key signer, which is unavailable on this device/build").
|
|
WithHint("omit --auth-method (or pass --auth-method client_secret) to register with an app secret")
|
|
}
|
|
return core.AuthMethodPrivateKeyJWT, nil
|
|
case core.AuthMethodClientSecret:
|
|
return core.AuthMethodClientSecret, nil
|
|
case "":
|
|
// fall through to interactive / default
|
|
default:
|
|
return "", errs.NewValidationError(errs.SubtypeInvalidArgument,
|
|
"unknown --auth-method %q (use client_secret or private_key_jwt)", flag)
|
|
}
|
|
|
|
if signerAvailable && f.IOStreams.IsTerminal {
|
|
var choice string
|
|
form := huh.NewForm(
|
|
huh.NewGroup(
|
|
huh.NewSelect[string]().
|
|
Title("Authentication method").
|
|
Options(
|
|
huh.NewOption("App Secret (client_secret)", core.AuthMethodClientSecret),
|
|
huh.NewOption("TEE signature, no secret (private_key_jwt)", core.AuthMethodPrivateKeyJWT),
|
|
).
|
|
Value(&choice),
|
|
),
|
|
).WithTheme(cmdutil.ThemeFeishu())
|
|
if err := form.Run(); err != nil {
|
|
if err == huh.ErrUserAborted {
|
|
return "", output.ErrBare(1)
|
|
}
|
|
return "", err
|
|
}
|
|
return choice, nil
|
|
}
|
|
return core.AuthMethodClientSecret, nil
|
|
}
|
|
|
|
// runCreateAppFlow runs the "create new app" flow via OpenClaw device flow.
|
|
// If brandOverride is non-empty, skip the interactive brand selection.
|
|
// authMethodFlag is the raw --auth-method value ("" when unset).
|
|
func runCreateAppFlow(ctx context.Context, f *cmdutil.Factory, brandOverride core.LarkBrand, authMethodFlag string, msg *initMsg) (*configInitResult, error) {
|
|
var larkBrand core.LarkBrand
|
|
if brandOverride != "" {
|
|
larkBrand = brandOverride
|
|
} else {
|
|
// Phase 2: Brand selection
|
|
var brand string
|
|
form2 := huh.NewForm(
|
|
huh.NewGroup(
|
|
huh.NewSelect[string]().
|
|
Title(msg.SelectPlatform).
|
|
Options(
|
|
huh.NewOption(msg.Feishu, "feishu"),
|
|
huh.NewOption("Lark", "lark"),
|
|
).
|
|
Value(&brand),
|
|
),
|
|
).WithTheme(cmdutil.ThemeFeishu())
|
|
|
|
if err := form2.Run(); err != nil {
|
|
if err == huh.ErrUserAborted {
|
|
return nil, output.ErrBare(1)
|
|
}
|
|
return nil, err
|
|
}
|
|
larkBrand = parseBrand(brand)
|
|
}
|
|
|
|
authMethod, err := resolveRegisterAuthMethod(f, authMethodFlag)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Step 1: Request app registration (begin).
|
|
// Use the shared proxy-plugin-aware transport so registration traffic is not
|
|
// a bypass of proxy plugin mode.
|
|
httpClient := transport.NewHTTPClient(0)
|
|
|
|
// For private_key_jwt: init to obtain a nonce, then sign a TEE attestation
|
|
// (carrying the public key in its jwk header) to send with begin.
|
|
beginOpts := larkauth.AppRegistrationBeginOptions{}
|
|
keyLabel := ""
|
|
if authMethod == core.AuthMethodPrivateKeyJWT {
|
|
signer := keysigner.Active() // non-nil, guaranteed by resolveRegisterAuthMethod
|
|
initResp, initErr := larkauth.RequestAppRegistrationInit(httpClient)
|
|
if initErr != nil {
|
|
return nil, errs.NewConfigError(errs.SubtypeInvalidClient, "app registration init failed: %v", initErr).WithCause(initErr)
|
|
}
|
|
// An empty SupportedAuthMethods is intentionally treated as "older server /
|
|
// unknown": len()==0 makes this guard false, so the requested
|
|
// private_key_jwt proceeds. This mirrors resolveFinalAuthMethod's
|
|
// back-compat fallback to the requested method. Only an explicit list that
|
|
// omits private_key_jwt rejects here.
|
|
if len(initResp.SupportedAuthMethods) > 0 && !slices.Contains(initResp.SupportedAuthMethods, core.AuthMethodPrivateKeyJWT) {
|
|
return nil, errs.NewConfigError(errs.SubtypeInvalidClient,
|
|
"server does not support private_key_jwt for this app type (supported: %s)", strings.Join(initResp.SupportedAuthMethods, ", ")).
|
|
WithHint("register with --auth-method client_secret instead")
|
|
}
|
|
keyLabel = keysigner.DefaultKeyLabel
|
|
attestation, signErr := jwt.SignAttestation(ctx, signer, keysigner.KeyRef{Label: keyLabel}, initResp.Nonce, time.Now())
|
|
if signErr != nil {
|
|
return nil, errs.NewConfigError(errs.SubtypeInvalidClient, "failed to sign registration attestation: %v", signErr).WithCause(signErr)
|
|
}
|
|
beginOpts = larkauth.AppRegistrationBeginOptions{
|
|
AuthMethod: core.AuthMethodPrivateKeyJWT,
|
|
AuthAttestation: attestation,
|
|
}
|
|
}
|
|
|
|
authResp, err := larkauth.RequestAppRegistration(httpClient, larkBrand, beginOpts, f.IOStreams.ErrOut)
|
|
if err != nil {
|
|
return nil, errs.NewConfigError(errs.SubtypeInvalidClient, "app registration failed: %v", err).WithCause(err)
|
|
}
|
|
|
|
// Step 2: Build and display verification URL + QR code
|
|
verificationURL := larkauth.BuildVerificationURL(authResp.VerificationUriComplete, build.Version)
|
|
|
|
// Branch on TTY: human-friendly copy in interactive terminals,
|
|
// preserve original copy for AI / non-interactive callers.
|
|
if f.IOStreams.IsTerminal {
|
|
fmt.Fprintf(f.IOStreams.ErrOut, "%s", msg.ScanQRCode)
|
|
qr, qrErr := qrcode.New(verificationURL, qrcode.Medium)
|
|
if qrErr == nil {
|
|
fmt.Fprint(f.IOStreams.ErrOut, qr.ToSmallString(false))
|
|
}
|
|
fmt.Fprintf(f.IOStreams.ErrOut, "%s", msg.ScanOrOpenLink)
|
|
fmt.Fprintf(f.IOStreams.ErrOut, " %s\n\n", verificationURL)
|
|
fmt.Fprintf(f.IOStreams.ErrOut, "%s\n", msg.WaitingForScan)
|
|
} else {
|
|
qr, qrErr := qrcode.New(verificationURL, qrcode.Medium)
|
|
if qrErr == nil {
|
|
fmt.Fprint(f.IOStreams.ErrOut, qr.ToSmallString(false))
|
|
}
|
|
fmt.Fprintf(f.IOStreams.ErrOut, "%s", msg.OpenLinkNonTTY)
|
|
fmt.Fprintf(f.IOStreams.ErrOut, " %s\n\n", verificationURL)
|
|
fmt.Fprintf(f.IOStreams.ErrOut, "%s\n", msg.WaitingForScanNonTTY)
|
|
}
|
|
result, err := larkauth.PollAppRegistration(ctx, httpClient, core.BrandFeishu, authResp.DeviceCode, authResp.Interval, authResp.ExpiresIn, f.IOStreams.ErrOut)
|
|
if err != nil {
|
|
return nil, errs.NewAuthenticationError(errs.SubtypeUnknown, "%v", err).WithCause(err)
|
|
}
|
|
|
|
// The final auth method is decided by the user/admin at confirmation and
|
|
// returned by poll — NOT necessarily what we requested. Selecting an existing
|
|
// client_secret app, for example, yields client_secret even though we sent
|
|
// private_key_jwt. Trust the result so we persist the truth.
|
|
finalMethod := resolveFinalAuthMethod(result.AuthMethods, authMethod)
|
|
|
|
// Lark brand special case (client_secret only): a lark-tenant app returns its
|
|
// secret only from the lark endpoint. private_key_jwt returns no secret, so
|
|
// this retry does not apply.
|
|
if finalMethod != core.AuthMethodPrivateKeyJWT && result.ClientSecret == "" && result.UserInfo != nil && result.UserInfo.TenantBrand == "lark" {
|
|
result, err = larkauth.PollAppRegistration(ctx, httpClient, core.BrandLark, authResp.DeviceCode, authResp.Interval, authResp.ExpiresIn, f.IOStreams.ErrOut)
|
|
if err != nil {
|
|
return nil, errs.NewNetworkError(errs.SubtypeNetworkTransport, "lark endpoint retry failed: %v", err).WithCause(err)
|
|
}
|
|
finalMethod = resolveFinalAuthMethod(result.AuthMethods, authMethod)
|
|
}
|
|
|
|
if result.ClientID == "" {
|
|
return nil, errs.NewConfigError(errs.SubtypeInvalidClient, "app registration succeeded but missing client_id")
|
|
}
|
|
if finalMethod != core.AuthMethodPrivateKeyJWT && result.ClientSecret == "" {
|
|
return nil, errs.NewConfigError(errs.SubtypeInvalidClient, "app registration succeeded but missing client_secret")
|
|
}
|
|
|
|
// Determine final brand from response
|
|
finalBrand := larkBrand
|
|
if result.UserInfo != nil && result.UserInfo.TenantBrand == "lark" {
|
|
finalBrand = core.BrandLark
|
|
} else if result.UserInfo != nil && result.UserInfo.TenantBrand == "feishu" {
|
|
finalBrand = core.BrandFeishu
|
|
}
|
|
|
|
// Surface a downgrade: requested private_key_jwt but the app resolved to a
|
|
// secret-based method (e.g. an existing app was selected). The key was NOT
|
|
// bound, so we must store the secret method, not private_key_jwt.
|
|
if authMethod == core.AuthMethodPrivateKeyJWT && finalMethod != core.AuthMethodPrivateKeyJWT {
|
|
fmt.Fprintf(f.IOStreams.ErrOut, "[lark-cli] note: requested private_key_jwt, but the app uses %q (e.g. an existing app was selected); storing %q.\n", finalMethod, finalMethod)
|
|
}
|
|
|
|
fmt.Fprintln(f.IOStreams.ErrOut)
|
|
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf(msg.AppCreated, result.ClientID))
|
|
|
|
keyToStore := ""
|
|
if finalMethod == core.AuthMethodPrivateKeyJWT {
|
|
keyToStore = keyLabel
|
|
}
|
|
return &configInitResult{
|
|
Mode: "create",
|
|
Brand: finalBrand,
|
|
AppID: result.ClientID,
|
|
AppSecret: result.ClientSecret, // empty for private_key_jwt; real secret otherwise
|
|
AuthMethod: finalMethod,
|
|
KeyLabel: keyToStore,
|
|
}, nil
|
|
}
|
|
|
|
// resolveFinalAuthMethod picks the authoritative method from the poll result,
|
|
// preferring private_key_jwt, then client_secret. It falls back to the requested
|
|
// method when the server returns nothing (older servers).
|
|
func resolveFinalAuthMethod(serverMethods []string, requested string) string {
|
|
if len(serverMethods) == 0 {
|
|
if requested == "" {
|
|
return core.AuthMethodClientSecret
|
|
}
|
|
return requested
|
|
}
|
|
for _, m := range serverMethods {
|
|
if m == core.AuthMethodPrivateKeyJWT {
|
|
return core.AuthMethodPrivateKeyJWT
|
|
}
|
|
}
|
|
for _, m := range serverMethods {
|
|
if m == core.AuthMethodClientSecret {
|
|
return core.AuthMethodClientSecret
|
|
}
|
|
}
|
|
return serverMethods[0]
|
|
}
|