Skip to content

Commit 2bbab4d

Browse files
authored
feat: validate credentials after config init (#1151)
* refactor: extract FetchTAT sharing the TAT-rejection classifier doResolveTAT minted the tenant access token inline. Extract the HTTP call into FetchTAT(ctx, httpClient, brand, appID, appSecret) so callers that already hold plaintext credentials — notably the post-config-init probe — can validate them without a second keychain round-trip. FetchTAT routes a non-zero TAT body code through the same classifyTATResponseCode the credential layer already uses, so a rejection is the canonical CategoryConfig / SubtypeInvalidClient (10003 / 10014) typed error — identical to what every token-resolving command returns. Transport, HTTP-status and JSON-parse failures stay raw (untyped) so callers can use errs.IsTyped to separate a deterministic credential rejection from upstream noise. doResolveTAT now delegates to FetchTAT; observable behavior unchanged. * feat: validate credentials after config init After config init saves the App ID / App Secret, fire a best-effort probe: mint a tenant access token with the just-saved credentials, then POST the application probe endpoint. When the credentials are deterministically rejected, FetchTAT returns a typed errs.* error and runProbe propagates it, so config init exits non-zero with the canonical ConfigError / invalid_client envelope (the same one every other command shows for the same bad creds) instead of letting the user discover the mistake on a later request. Ambiguous failures (transport, HTTP non-200, JSON parse, timeout, http-client init) come back untyped and are swallowed (errs.IsTyped is the discriminator), so a valid configuration is never blocked by upstream noise. The probe is wired into all four init paths and skipped when the user reused an existing secret. The saved config is not rolled back on rejection: stdout still records what was saved, stderr carries the typed error envelope.
1 parent 98173ae commit 2bbab4d

6 files changed

Lines changed: 704 additions & 37 deletions

File tree

cmd/config/init.go

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -341,6 +341,9 @@ func configInitRun(opts *ConfigInitOptions) error {
341341
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf("Configuration saved to %s", core.GetConfigPath()))
342342
printLangPreferenceConfirmation(opts)
343343
output.PrintJson(f.IOStreams.Out, map[string]interface{}{"appId": opts.AppID, "appSecret": "****", "brand": brand})
344+
if err := runProbe(opts.Ctx, f, opts.AppID, opts.appSecret, brand); err != nil {
345+
return err
346+
}
344347
return nil
345348
}
346349

@@ -380,6 +383,9 @@ func configInitRun(opts *ConfigInitOptions) error {
380383
}
381384
printLangPreferenceConfirmation(opts)
382385
output.PrintJson(f.IOStreams.Out, map[string]interface{}{"appId": result.AppID, "appSecret": "****", "brand": result.Brand})
386+
if err := runProbe(opts.Ctx, f, result.AppID, result.AppSecret, result.Brand); err != nil {
387+
return err
388+
}
383389
return nil
384390
}
385391

@@ -419,6 +425,11 @@ func configInitRun(opts *ConfigInitOptions) error {
419425
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf(msg.ConfigSaved, result.AppID))
420426
}
421427
printLangPreferenceConfirmation(opts)
428+
if result.AppSecret != "" {
429+
if err := runProbe(opts.Ctx, f, result.AppID, result.AppSecret, result.Brand); err != nil {
430+
return err
431+
}
432+
}
422433
return nil
423434
}
424435

@@ -507,5 +518,10 @@ func configInitRun(opts *ConfigInitOptions) error {
507518
}
508519
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf("Configuration saved to %s", core.GetConfigPath()))
509520
printLangPreferenceConfirmation(opts)
521+
if appSecretInput != "" {
522+
if err := runProbe(opts.Ctx, f, resolvedAppId, appSecretInput, parseBrand(resolvedBrand)); err != nil {
523+
return err
524+
}
525+
}
510526
return nil
511527
}

cmd/config/init_probe.go

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
2+
// SPDX-License-Identifier: MIT
3+
4+
package config
5+
6+
import (
7+
"bytes"
8+
"context"
9+
"fmt"
10+
"io"
11+
"net/http"
12+
"time"
13+
14+
"github.com/larksuite/cli/errs"
15+
"github.com/larksuite/cli/internal/build"
16+
"github.com/larksuite/cli/internal/cmdutil"
17+
"github.com/larksuite/cli/internal/core"
18+
"github.com/larksuite/cli/internal/credential"
19+
)
20+
21+
// probeTimeout is the total wall-clock budget for the credential probe step
22+
// (covering both TAT acquisition and the subsequent probe request).
23+
const probeTimeout = 3 * time.Second
24+
25+
// runProbe runs a best-effort credential validation after config init has
26+
// persisted the App ID and App Secret. It returns a non-nil error only for a
27+
// deterministic credential-rejection signal; every other outcome returns nil
28+
// so that valid configurations and transient/upstream noise never block the
29+
// command.
30+
//
31+
// The function performs up to two HTTP calls in series, bounded by
32+
// probeTimeout:
33+
//
34+
// 1. A TAT request using the just-saved credentials. credential.FetchTAT
35+
// returns a typed errs.* error (via the shared classifyTATResponseCode)
36+
// only when the server deterministically rejected the credentials — a
37+
// non-zero TAT body code, classified as CategoryConfig / SubtypeInvalidClient
38+
// (10003 / 10014) or whatever codemeta maps. That typed error is propagated
39+
// so the root dispatcher renders the canonical envelope and `config init`
40+
// exits non-zero — identical to how every other token-resolving command
41+
// reports the same bad credentials. Ambiguous failures (transport errors,
42+
// HTTP non-200, JSON parse errors, timeouts) come back as raw untyped
43+
// errors and are swallowed (return nil), so valid configurations are never
44+
// disturbed by upstream noise. errs.IsTyped is the discriminator.
45+
//
46+
// 2. If TAT succeeded, a POST to the probe endpoint is fired. The outcome of
47+
// that call (success, server error, timeout, parse failure) is always
48+
// ignored — return nil regardless.
49+
func runProbe(parent context.Context, factory *cmdutil.Factory, appID, appSecret string, brand core.LarkBrand) error {
50+
if factory == nil {
51+
return nil
52+
}
53+
httpClient, err := factory.HttpClient()
54+
if err != nil {
55+
return nil
56+
}
57+
58+
ctx, cancel := context.WithTimeout(parent, probeTimeout)
59+
defer cancel()
60+
61+
token, err := credential.FetchTAT(ctx, httpClient, brand, appID, appSecret)
62+
if err != nil {
63+
// A typed error from FetchTAT is a deterministic credential rejection
64+
// (classifyTATResponseCode). Propagate it so config init exits with the
65+
// same envelope the rest of the CLI uses for bad credentials. Untyped
66+
// errors are ambiguous (transport / HTTP / parse / timeout) — stay
67+
// silent and let the command succeed.
68+
if errs.IsTyped(err) {
69+
return err
70+
}
71+
return nil
72+
}
73+
74+
// TAT succeeded — fire the probe call. Any outcome is ignored.
75+
url := core.ResolveEndpoints(brand).Open + "/open-apis/application/v6/larksuite_cli_app/probe"
76+
body := []byte(fmt.Sprintf(`{"from":"lark-cli/%s"}`, build.Version))
77+
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
78+
if err != nil {
79+
return nil
80+
}
81+
req.Header.Set("Authorization", "Bearer "+token)
82+
req.Header.Set("Content-Type", "application/json")
83+
84+
resp, err := httpClient.Do(req)
85+
if err != nil {
86+
return nil
87+
}
88+
defer resp.Body.Close()
89+
_, _ = io.Copy(io.Discard, resp.Body)
90+
return nil
91+
}

0 commit comments

Comments
 (0)