mirror of
https://github.com/larksuite/cli.git
synced 2026-09-14 18:42:53 +08:00
fdc1ba4df7
* feat: use remote scopes.json for login scope recommendations Switch auth login's recommended-permission source from the compiled-in local table to a scopes.json fetched from the platform at login time. - add internal/auth/remote_scopes.go: brand-addressed GET with a ~1s timeout and whole-file (binary) validation; scopes are used verbatim when the file is valid, and any fetch/parse/format failure falls back silently to the existing local computation - login.go: fetch remote scopes once per login and use them for domain validation, all-expansion, and per-domain scope selection; drop the terminal interactive page and the local auto-approve filter chain, so --recommend is now equivalent to --domain all - remove login_interactive.go and the service-description getters left orphaned by the interactive-page removal - keep the three entry flags (bare login / --recommend / --domain all) as an equivalent transitional surface * refactor: remove orphaned auto-approve loader and harden scope check * fix: accept variable segment counts in remote scope validation * feat(auth): add support for status message from device flow auth * fix(auth): remove message field from login warning payload * fix(auth/login): improve login result heading and warning hint logic * fix: resolve auth scopes locally for custom builds and exclusions Remote-first login (reading the published scopes.json) can drop scopes that a specific build or the local resolution still legitimately covers. Two cases are now handled locally: - A build that injected business commands via WithCommandSets has a scope universe the standard-CLI scopes.json does not cover. Detect it by comparing registered command paths against the built-in set and skip the remote fetch, so such a build resolves locally instead of silently losing its custom scopes. - Fold the local domain resolution into the --exclude validation universe. Once the server drops a batch-withheld scope (im:message.send_as_user) from the published list, the remote candidate set no longer carries it, but --domain im --exclude im:message.send_as_user must stay a valid no-op; a domain that never had the scope still rejects it as unknown. Also raise the remote fetch timeout from 1s to 2s, and make the auth tests hermetic by defaulting the remote fetch to unavailable in TestMain (a test that needs a specific remote overrides the seam). ---------
301 lines
8.7 KiB
Go
301 lines
8.7 KiB
Go
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||
// SPDX-License-Identifier: MIT
|
||
|
||
package auth
|
||
|
||
import (
|
||
"context"
|
||
"encoding/base64"
|
||
"encoding/json"
|
||
"fmt"
|
||
"io"
|
||
"net/http"
|
||
"net/url"
|
||
"strings"
|
||
"time"
|
||
|
||
"github.com/larksuite/cli/internal/core"
|
||
)
|
||
|
||
// DeviceAuthResponse is the response from the device authorization endpoint.
|
||
type DeviceAuthResponse struct {
|
||
DeviceCode string `json:"device_code"`
|
||
UserCode string `json:"user_code"`
|
||
VerificationUri string `json:"verification_uri"`
|
||
VerificationUriComplete string `json:"verification_uri_complete"`
|
||
ExpiresIn int `json:"expires_in"`
|
||
Interval int `json:"interval"`
|
||
}
|
||
|
||
// DeviceFlowTokenData contains the token data from a successful device flow.
|
||
type DeviceFlowTokenData struct {
|
||
AccessToken string
|
||
RefreshToken string
|
||
ExpiresIn int
|
||
RefreshExpiresIn int
|
||
Scope string
|
||
StatusMessage string
|
||
}
|
||
|
||
// DeviceFlowResult is the result of polling the token endpoint.
|
||
type DeviceFlowResult struct {
|
||
OK bool
|
||
Token *DeviceFlowTokenData
|
||
Error string
|
||
Message string
|
||
}
|
||
|
||
// OAuthEndpoints contains the OAuth endpoint URLs.
|
||
type OAuthEndpoints struct {
|
||
DeviceAuthorization string
|
||
Revoke string
|
||
Token string
|
||
}
|
||
|
||
// ResolveOAuthEndpoints resolves OAuth endpoint URLs based on brand.
|
||
func ResolveOAuthEndpoints(brand core.LarkBrand) OAuthEndpoints {
|
||
ep := core.ResolveEndpoints(brand)
|
||
return OAuthEndpoints{
|
||
DeviceAuthorization: ep.Accounts + PathDeviceAuthorization,
|
||
Revoke: ep.Accounts + PathOAuthRevoke,
|
||
Token: ep.Open + PathOAuthTokenV2,
|
||
}
|
||
}
|
||
|
||
// RequestDeviceAuthorization requests a device authorization code.
|
||
func RequestDeviceAuthorization(httpClient *http.Client, appId, appSecret string, brand core.LarkBrand, scope string, errOut io.Writer) (*DeviceAuthResponse, error) {
|
||
if errOut == nil {
|
||
errOut = io.Discard
|
||
}
|
||
|
||
endpoints := ResolveOAuthEndpoints(brand)
|
||
|
||
if !strings.Contains(scope, "offline_access") {
|
||
if scope != "" {
|
||
scope = scope + " offline_access"
|
||
} else {
|
||
scope = "offline_access"
|
||
}
|
||
}
|
||
|
||
basicAuth := base64.StdEncoding.EncodeToString([]byte(appId + ":" + appSecret))
|
||
|
||
form := url.Values{}
|
||
form.Set("client_id", appId)
|
||
form.Set("scope", scope)
|
||
|
||
req, err := http.NewRequest("POST", endpoints.DeviceAuthorization, strings.NewReader(form.Encode()))
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||
req.Header.Set("Authorization", "Basic "+basicAuth)
|
||
|
||
resp, err := httpClient.Do(req)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
defer resp.Body.Close()
|
||
logHTTPResponse(resp)
|
||
|
||
body, err := io.ReadAll(resp.Body)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("Device authorization failed: read body: %v", err)
|
||
}
|
||
|
||
var data map[string]interface{}
|
||
if err := json.Unmarshal(body, &data); err != nil {
|
||
return nil, fmt.Errorf("Device authorization failed: HTTP %d – response not JSON", resp.StatusCode)
|
||
}
|
||
|
||
_, hasError := data["error"]
|
||
if resp.StatusCode >= 400 || hasError {
|
||
msg := getStr(data, "error_description")
|
||
if msg == "" {
|
||
msg = getStr(data, "error")
|
||
}
|
||
if msg == "" {
|
||
msg = "Unknown error"
|
||
}
|
||
return nil, fmt.Errorf("Device authorization failed: %s", msg)
|
||
}
|
||
|
||
expiresIn := getInt(data, "expires_in", 240)
|
||
interval := getInt(data, "interval", 5)
|
||
|
||
verificationUri := getStr(data, "verification_uri")
|
||
verificationUriComplete := getStr(data, "verification_uri_complete")
|
||
if verificationUriComplete == "" {
|
||
verificationUriComplete = verificationUri
|
||
}
|
||
|
||
return &DeviceAuthResponse{
|
||
DeviceCode: getStr(data, "device_code"),
|
||
UserCode: getStr(data, "user_code"),
|
||
VerificationUri: verificationUri,
|
||
VerificationUriComplete: verificationUriComplete,
|
||
ExpiresIn: expiresIn,
|
||
Interval: interval,
|
||
}, nil
|
||
}
|
||
|
||
// PollDeviceToken polls the token endpoint until authorization completes or times out.
|
||
func PollDeviceToken(ctx context.Context, httpClient *http.Client, appId, appSecret string, brand core.LarkBrand, deviceCode string, interval, expiresIn int, errOut io.Writer) *DeviceFlowResult {
|
||
if errOut == nil {
|
||
errOut = io.Discard
|
||
}
|
||
|
||
if interval < 1 {
|
||
interval = 5
|
||
}
|
||
|
||
const maxPollInterval = 60
|
||
const maxPollAttempts = 600
|
||
|
||
endpoints := ResolveOAuthEndpoints(brand)
|
||
deadline := time.Now().Add(time.Duration(expiresIn) * time.Second)
|
||
currentInterval := interval
|
||
attempts := 0
|
||
|
||
for time.Now().Before(deadline) && attempts < maxPollAttempts {
|
||
attempts++
|
||
if ctx.Err() != nil {
|
||
return &DeviceFlowResult{OK: false, Error: "expired_token", Message: "Polling was cancelled"}
|
||
}
|
||
|
||
select {
|
||
case <-time.After(time.Duration(currentInterval) * time.Second):
|
||
case <-ctx.Done():
|
||
return &DeviceFlowResult{OK: false, Error: "expired_token", Message: "Polling was cancelled"}
|
||
}
|
||
|
||
form := url.Values{}
|
||
form.Set("grant_type", "urn:ietf:params:oauth:grant-type:device_code")
|
||
form.Set("device_code", deviceCode)
|
||
form.Set("client_id", appId)
|
||
form.Set("client_secret", appSecret)
|
||
|
||
req, err := http.NewRequest("POST", endpoints.Token, strings.NewReader(form.Encode()))
|
||
if err != nil {
|
||
continue
|
||
}
|
||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||
|
||
resp, err := httpClient.Do(req)
|
||
if err != nil {
|
||
fmt.Fprintf(errOut, "[lark-cli] [WARN] device-flow: poll network error: %v\n", err)
|
||
currentInterval = minInt(currentInterval+1, maxPollInterval)
|
||
continue
|
||
}
|
||
logHTTPResponse(resp)
|
||
|
||
body, err := io.ReadAll(resp.Body)
|
||
resp.Body.Close()
|
||
if err != nil {
|
||
fmt.Fprintf(errOut, "[lark-cli] [WARN] device-flow: poll read error: %v\n", err)
|
||
currentInterval = minInt(currentInterval+1, maxPollInterval)
|
||
continue
|
||
}
|
||
|
||
var data map[string]interface{}
|
||
if err := json.Unmarshal(body, &data); err != nil {
|
||
fmt.Fprintf(errOut, "[lark-cli] [WARN] device-flow: poll parse error: %v\n", err)
|
||
currentInterval = minInt(currentInterval+1, maxPollInterval)
|
||
continue
|
||
}
|
||
|
||
errStr := getStr(data, "error")
|
||
|
||
if errStr == "" && getStr(data, "access_token") != "" {
|
||
fmt.Fprintf(errOut, "[lark-cli] device-flow: token response received\n")
|
||
refreshToken := getStr(data, "refresh_token")
|
||
tokenExpiresIn := getInt(data, "expires_in", 7200)
|
||
refreshExpiresIn := getInt(data, "refresh_token_expires_in", 604800)
|
||
if refreshToken == "" {
|
||
fmt.Fprintf(errOut, "[lark-cli] [WARN] device-flow: no refresh_token in response\n")
|
||
refreshExpiresIn = tokenExpiresIn
|
||
}
|
||
return &DeviceFlowResult{
|
||
OK: true,
|
||
Token: &DeviceFlowTokenData{
|
||
AccessToken: getStr(data, "access_token"),
|
||
RefreshToken: refreshToken,
|
||
ExpiresIn: tokenExpiresIn,
|
||
RefreshExpiresIn: refreshExpiresIn,
|
||
Scope: getStr(data, "scope"),
|
||
StatusMessage: getStr(data, "status_message"),
|
||
},
|
||
}
|
||
}
|
||
|
||
switch errStr {
|
||
case "authorization_pending":
|
||
continue
|
||
case "slow_down":
|
||
currentInterval = minInt(currentInterval+5, maxPollInterval)
|
||
fmt.Fprintf(errOut, "[lark-cli] device-flow: slow_down, interval increased to %ds\n", currentInterval)
|
||
continue
|
||
case "access_denied":
|
||
msg := getStr(data, "error_description")
|
||
if msg == "" {
|
||
msg = "Authorization denied by user"
|
||
}
|
||
return &DeviceFlowResult{OK: false, Error: "access_denied", Message: msg}
|
||
case "expired_token", "invalid_grant":
|
||
msg := getStr(data, "error_description")
|
||
if msg == "" {
|
||
msg = "Device code expired, please try again"
|
||
}
|
||
return &DeviceFlowResult{OK: false, Error: "expired_token", Message: msg}
|
||
}
|
||
|
||
desc := getStr(data, "error_description")
|
||
if desc == "" {
|
||
desc = errStr
|
||
}
|
||
if desc == "" {
|
||
desc = "Unknown error"
|
||
}
|
||
fmt.Fprintf(errOut, "[lark-cli] [WARN] device-flow: unexpected error: error=%s, desc=%s\n", errStr, desc)
|
||
return &DeviceFlowResult{OK: false, Error: "expired_token", Message: desc}
|
||
}
|
||
|
||
if attempts >= maxPollAttempts {
|
||
fmt.Fprintf(errOut, "[lark-cli] [WARN] device-flow: max poll attempts (%d) reached\n", maxPollAttempts)
|
||
}
|
||
return &DeviceFlowResult{OK: false, Error: "expired_token", Message: "Authorization timed out, please try again"}
|
||
}
|
||
|
||
// helpers
|
||
|
||
// minInt returns the smaller of a or b.
|
||
func minInt(a, b int) int {
|
||
if a < b {
|
||
return a
|
||
}
|
||
return b
|
||
}
|
||
|
||
// getStr retrieves a string value from a map, returning an empty string if not found or not a string.
|
||
func getStr(m map[string]interface{}, key string) string {
|
||
if v, ok := m[key]; ok {
|
||
if s, ok := v.(string); ok {
|
||
return s
|
||
}
|
||
}
|
||
return ""
|
||
}
|
||
|
||
// getInt retrieves an integer value from a map, returning a fallback value if not found or not a number.
|
||
func getInt(m map[string]interface{}, key string, fallback int) int {
|
||
if v, ok := m[key]; ok {
|
||
switch n := v.(type) {
|
||
case float64:
|
||
return int(n)
|
||
case int:
|
||
return n
|
||
}
|
||
}
|
||
return fallback
|
||
}
|