1722 lines
51 KiB
Go
1722 lines
51 KiB
Go
// Copyright 2026 Alibaba Group
|
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
// you may not use this file except in compliance with the License.
|
|
// You may obtain a copy of the License at
|
|
//
|
|
// http://www.apache.org/licenses/LICENSE-2.0
|
|
//
|
|
// Unless required by applicable law or agreed to in writing, software
|
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
// See the License for the specific language governing permissions and
|
|
// limitations under the License.
|
|
|
|
package auth
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"html"
|
|
"io"
|
|
"log/slog"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"slices"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
|
|
"github.com/google/uuid"
|
|
)
|
|
|
|
var (
|
|
oauthSaveClientSecret = SaveClientSecret
|
|
oauthSaveTokenLocked = saveTokenDataLocked
|
|
oauthRetryAfter = time.After
|
|
oauthNewRequest = http.NewRequestWithContext
|
|
)
|
|
|
|
func (p *OAuthProvider) exchangeCode(ctx context.Context, code string) (*TokenData, error) {
|
|
// Use MCP mode if clientID is from MCP server
|
|
if IsClientIDFromMCP() {
|
|
return p.exchangeCodeViaMCP(ctx, code)
|
|
}
|
|
// Direct mode with client secret
|
|
clientID := ClientID()
|
|
clientSecret := ClientSecret()
|
|
body := map[string]string{
|
|
"clientId": clientID,
|
|
"clientSecret": clientSecret,
|
|
"code": code,
|
|
"grantType": "authorization_code",
|
|
}
|
|
resp, err := p.postJSON(ctx, UserAccessTokenURLForLoginRegion(p.loginRegion()), body)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
data, err := p.parseTokenResponse(resp)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
// Snapshot credentials used for this token (for refresh)
|
|
data.ClientID = clientID
|
|
data.Source = resolveCredentialSource()
|
|
p.applyLoginRegionToToken(data)
|
|
// Save clientSecret for future refresh (even if env changes)
|
|
if err := oauthSaveClientSecret(clientID, clientSecret); err != nil {
|
|
// Log warning but don't fail login
|
|
fmt.Fprintf(p.Output, "Warning: failed to save client secret: %v\n", err)
|
|
}
|
|
return data, nil
|
|
}
|
|
|
|
// ExchangeCodeForToken exchanges an authorization code for token data using
|
|
// the currently configured client credentials. This is a convenience wrapper
|
|
// around OAuthProvider.exchangeCode for callers outside the auth package.
|
|
func ExchangeCodeForToken(ctx context.Context, configDir, code string) (*TokenData, error) {
|
|
if err := prepareLoginPersistence(configDir); err != nil {
|
|
return nil, fmt.Errorf("local login state cannot be safely updated before token exchange: %w", err)
|
|
}
|
|
p := &OAuthProvider{
|
|
configDir: configDir,
|
|
clientID: ClientID(),
|
|
Output: io.Discard,
|
|
httpClient: oauthHTTPClient,
|
|
}
|
|
data, err := p.exchangeCode(ctx, code)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
data.FreshAuthorization = true
|
|
return data, nil
|
|
}
|
|
|
|
func (p *OAuthProvider) loginRegion() LoginRegion {
|
|
if p == nil {
|
|
return LoginRegionDefault
|
|
}
|
|
return p.LoginRegion
|
|
}
|
|
|
|
func (p *OAuthProvider) useTokenLoginRegion(data *TokenData) {
|
|
if p == nil || p.LoginRegion != LoginRegionDefault || data == nil {
|
|
return
|
|
}
|
|
if region := LoginRegion(strings.TrimSpace(data.LoginRegion)); region != LoginRegionDefault {
|
|
p.LoginRegion = region
|
|
}
|
|
}
|
|
|
|
func (p *OAuthProvider) applyLoginRegionToToken(data *TokenData) {
|
|
if data == nil {
|
|
return
|
|
}
|
|
if region := p.loginRegion(); region != LoginRegionDefault {
|
|
data.LoginRegion = string(region)
|
|
}
|
|
}
|
|
|
|
// exchangeCodeViaMCP exchanges auth code for token via MCP proxy.
|
|
// This is used when client secret is not available (server-side secret management).
|
|
func (p *OAuthProvider) exchangeCodeViaMCP(ctx context.Context, code string) (*TokenData, error) {
|
|
clientID := ClientID()
|
|
url := MCPBaseURLForLoginRegion(p.loginRegion()) + MCPOAuthTokenPath
|
|
body := map[string]string{
|
|
"clientId": clientID,
|
|
"authCode": code,
|
|
"grantType": "authorization_code",
|
|
}
|
|
resp, err := p.postJSON(ctx, url, body)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
data, err := p.parseMCPTokenResponse(resp)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
// Snapshot credentials used for this token (for refresh)
|
|
data.ClientID = clientID
|
|
data.Source = "mcp"
|
|
p.applyLoginRegionToToken(data)
|
|
// MCP mode doesn't need to save clientSecret (server-side managed)
|
|
return data, nil
|
|
}
|
|
|
|
func (p *OAuthProvider) refreshWithRefreshToken(ctx context.Context, data *TokenData) (*TokenData, error) {
|
|
// Use stored Source to determine refresh path (not current runtime state)
|
|
// This ensures refresh works even if environment variables changed since login
|
|
if data.Source == "mcp" {
|
|
p.useTokenLoginRegion(data)
|
|
return p.refreshViaMCP(ctx, data)
|
|
}
|
|
p.useTokenLoginRegion(data)
|
|
|
|
// Direct mode: use stored clientId and load saved clientSecret
|
|
clientID := data.ClientID
|
|
if clientID == "" {
|
|
// Fallback for legacy tokens without stored clientId
|
|
clientID = ClientID()
|
|
}
|
|
clientSecret := LoadClientSecret(clientID)
|
|
if clientSecret == "" {
|
|
// Fallback: try current environment
|
|
clientSecret = ClientSecret()
|
|
}
|
|
|
|
if clientID == "" || clientSecret == "" || strings.HasPrefix(clientSecret, "<") {
|
|
return nil, fmt.Errorf("无法刷新 token: 缺少 clientId 或 clientSecret,请重新登录")
|
|
}
|
|
|
|
body := map[string]string{
|
|
"clientId": clientID,
|
|
"clientSecret": clientSecret,
|
|
"refreshToken": data.RefreshToken,
|
|
"grantType": "refresh_token",
|
|
}
|
|
resp, err := p.postJSON(ctx, UserAccessTokenURLForLoginRegion(p.loginRegion()), body)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
updated, err := p.parseTokenResponse(resp)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
// Preserve original credentials info
|
|
updated.ClientID = data.ClientID
|
|
updated.Source = data.Source
|
|
updated.LoginRegion = data.LoginRegion
|
|
updated.PersistentCode = data.PersistentCode
|
|
updated.CorpID = data.CorpID
|
|
updated.UserID = data.UserID
|
|
updated.UserName = data.UserName
|
|
if updated.CorpName == "" {
|
|
updated.CorpName = data.CorpName
|
|
}
|
|
|
|
// Refresh runs under lockedRefresh's dual-layer lock; use the lock-free
|
|
// saver to avoid re-acquiring the non-reentrant lock (deadlock).
|
|
if err := oauthSaveTokenLocked(p.configDir, updated); err != nil {
|
|
return nil, fmt.Errorf("保存刷新后的 token 失败(旧 refresh_token 已失效,请重新登录): %w", err)
|
|
}
|
|
return updated, nil
|
|
}
|
|
|
|
// refreshViaMCP refreshes token via MCP proxy.
|
|
func (p *OAuthProvider) refreshViaMCP(ctx context.Context, data *TokenData) (*TokenData, error) {
|
|
// Use stored clientId from token data
|
|
clientID := data.ClientID
|
|
if clientID == "" {
|
|
// Fallback for legacy tokens
|
|
clientID = ClientID()
|
|
}
|
|
|
|
if clientID == "" {
|
|
return nil, fmt.Errorf("无法刷新 token: 缺少 clientId,请重新登录")
|
|
}
|
|
|
|
url := MCPBaseURLForLoginRegion(p.loginRegion()) + MCPRefreshTokenPath
|
|
body := map[string]string{
|
|
"clientId": clientID,
|
|
"refreshToken": data.RefreshToken,
|
|
"grantType": "refresh_token",
|
|
}
|
|
resp, err := p.postJSON(ctx, url, body)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
updated, err := p.parseMCPTokenResponse(resp)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
// Preserve original credentials info
|
|
updated.ClientID = data.ClientID
|
|
updated.Source = data.Source
|
|
updated.LoginRegion = data.LoginRegion
|
|
updated.PersistentCode = data.PersistentCode
|
|
updated.CorpID = data.CorpID
|
|
updated.UserID = data.UserID
|
|
updated.UserName = data.UserName
|
|
if updated.CorpName == "" {
|
|
updated.CorpName = data.CorpName
|
|
}
|
|
|
|
// Refresh runs under lockedRefresh's dual-layer lock; use the lock-free
|
|
// saver to avoid re-acquiring the non-reentrant lock (deadlock).
|
|
if err := oauthSaveTokenLocked(p.configDir, updated); err != nil {
|
|
return nil, fmt.Errorf("保存刷新后的 token 失败(旧 refresh_token 已失效,请重新登录): %w", err)
|
|
}
|
|
return updated, nil
|
|
}
|
|
|
|
func (p *OAuthProvider) postJSON(ctx context.Context, endpoint string, body any) ([]byte, error) {
|
|
b, err := json.Marshal(body)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("marshaling request: %w", err)
|
|
}
|
|
req, err := oauthNewRequest(ctx, http.MethodPost, endpoint, bytes.NewReader(b))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("creating request: %w", err)
|
|
}
|
|
req.Header.Set("Content-Type", "application/json")
|
|
applyEditionEnterpriseCredentialHeaders(req)
|
|
|
|
client := p.httpClient
|
|
if client == nil {
|
|
client = oauthHTTPClient
|
|
}
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("sending request: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
data, readErr := io.ReadAll(io.LimitReader(resp.Body, config.MaxResponseBodySize))
|
|
if resp.StatusCode != http.StatusOK {
|
|
// Preserve structured HTTP status semantics even when the response
|
|
// body is truncated. The body is diagnostic-only here, so read it
|
|
// best-effort and classify retryability from the status code.
|
|
if readErr != nil {
|
|
data = nil
|
|
}
|
|
return nil, &HTTPStatusError{
|
|
StatusCode: resp.StatusCode,
|
|
responseBody: truncateBody(data, 200),
|
|
}
|
|
}
|
|
if readErr != nil {
|
|
return nil, fmt.Errorf("reading response: %w", readErr)
|
|
}
|
|
return data, nil
|
|
}
|
|
|
|
func (p *OAuthProvider) parseTokenResponse(body []byte) (*TokenData, error) {
|
|
var resp struct {
|
|
AccessToken string `json:"accessToken"`
|
|
RefreshToken string `json:"refreshToken"`
|
|
PersistentCode string `json:"persistentCode"`
|
|
ExpiresIn int64 `json:"expiresIn"`
|
|
CorpID string `json:"corpId"`
|
|
}
|
|
if err := json.Unmarshal(body, &resp); err != nil {
|
|
return nil, fmt.Errorf("parsing token response: %w", err)
|
|
}
|
|
if resp.AccessToken == "" {
|
|
return nil, fmt.Errorf("token response missing accessToken")
|
|
}
|
|
|
|
now := time.Now()
|
|
expiresIn := resp.ExpiresIn
|
|
if expiresIn <= 0 {
|
|
// 默认 2 小时有效期(钉钉 access_token 标准有效期)
|
|
expiresIn = config.DefaultAccessTokenExpiry
|
|
}
|
|
data := &TokenData{
|
|
AccessToken: resp.AccessToken,
|
|
RefreshToken: resp.RefreshToken,
|
|
ExpiresAt: now.Add(time.Duration(expiresIn) * time.Second),
|
|
RefreshExpAt: now.Add(config.DefaultRefreshTokenLifetime),
|
|
CorpID: resp.CorpID,
|
|
}
|
|
if resp.PersistentCode != "" {
|
|
data.PersistentCode = resp.PersistentCode
|
|
}
|
|
return data, nil
|
|
}
|
|
|
|
const legacyMCPRefreshRejectedCode = "invalidParameter.authCode.notFound"
|
|
|
|
// MCPTokenExchangeError preserves the backend business code so refresh callers
|
|
// can distinguish a legacy credential that requires a new authorization from
|
|
// transient transport failures.
|
|
type MCPTokenExchangeError struct {
|
|
Code string
|
|
Message string
|
|
}
|
|
|
|
func (e *MCPTokenExchangeError) Error() string {
|
|
return fmt.Sprintf("MCP token exchange failed: %s - %s", e.Code, e.Message)
|
|
}
|
|
|
|
func (e *MCPTokenExchangeError) requiresReauthorization() bool {
|
|
return strings.TrimSpace(e.Code) == legacyMCPRefreshRejectedCode
|
|
}
|
|
|
|
// parseMCPTokenResponse parses token response from MCP proxy.
|
|
// MCP OAuth response format: {"accessToken": "...", "refreshToken": "...", "expiresIn": 7200, "corpId": "...", "corpName": "..."}
|
|
func (p *OAuthProvider) parseMCPTokenResponse(body []byte) (*TokenData, error) {
|
|
var resp struct {
|
|
AccessToken string `json:"accessToken"`
|
|
RefreshToken string `json:"refreshToken"`
|
|
PersistentCode string `json:"persistentCode"`
|
|
ExpiresIn int64 `json:"expiresIn"`
|
|
CorpID string `json:"corpId"`
|
|
CorpName string `json:"corpName"`
|
|
CorpNameSnake string `json:"corp_name"`
|
|
OrgName string `json:"orgName"`
|
|
UserID string `json:"userId"`
|
|
UserName string `json:"userName"`
|
|
// Error fields (when request fails)
|
|
ErrorCode string `json:"errorCode,omitempty"`
|
|
ErrorMsg string `json:"errorMsg,omitempty"`
|
|
}
|
|
if err := json.Unmarshal(body, &resp); err != nil {
|
|
return nil, fmt.Errorf("parsing MCP token response: %w (body: %s)", err, string(body))
|
|
}
|
|
// Check for error response
|
|
if resp.ErrorCode != "" || resp.ErrorMsg != "" {
|
|
return nil, &MCPTokenExchangeError{Code: resp.ErrorCode, Message: resp.ErrorMsg}
|
|
}
|
|
if resp.AccessToken == "" {
|
|
return nil, fmt.Errorf("MCP token response missing accessToken (body: %s)", string(body))
|
|
}
|
|
|
|
now := time.Now()
|
|
expiresIn := resp.ExpiresIn
|
|
if expiresIn <= 0 {
|
|
expiresIn = config.DefaultAccessTokenExpiry
|
|
}
|
|
data := &TokenData{
|
|
AccessToken: resp.AccessToken,
|
|
RefreshToken: resp.RefreshToken,
|
|
ExpiresAt: now.Add(time.Duration(expiresIn) * time.Second),
|
|
RefreshExpAt: now.Add(config.DefaultRefreshTokenLifetime),
|
|
CorpID: resp.CorpID,
|
|
CorpName: firstNonEmpty(resp.CorpName, resp.CorpNameSnake, resp.OrgName),
|
|
UserID: resp.UserID,
|
|
UserName: resp.UserName,
|
|
}
|
|
if resp.PersistentCode != "" {
|
|
data.PersistentCode = resp.PersistentCode
|
|
}
|
|
return data, nil
|
|
}
|
|
|
|
func firstNonEmpty(values ...string) string {
|
|
for _, v := range values {
|
|
if trimmed := strings.TrimSpace(v); trimmed != "" {
|
|
return trimmed
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func buildAuthURL(clientID, redirectURI, targetCorpID string) string {
|
|
return buildAuthURLForRegion(clientID, redirectURI, targetCorpID, LoginRegionDefault)
|
|
}
|
|
|
|
func buildAuthURLForRegion(clientID, redirectURI, targetCorpID string, region LoginRegion) string {
|
|
params := url.Values{
|
|
"client_id": {clientID},
|
|
"redirect_uri": {redirectURI},
|
|
"response_type": {"code"},
|
|
"scope": {DefaultScopes},
|
|
"prompt": {"consent"},
|
|
}
|
|
if targetCorpID = strings.TrimSpace(targetCorpID); targetCorpID != "" {
|
|
params.Set("corpId", targetCorpID)
|
|
}
|
|
return AuthorizeURLForLoginRegion(region) + "?" + params.Encode()
|
|
}
|
|
|
|
const successHTML = `<!doctype html>
|
|
<html>
|
|
<head>
|
|
<meta charset="utf-8" />
|
|
<title>钉钉 CLI</title>
|
|
<style>
|
|
body {
|
|
font-family:
|
|
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
|
|
"Helvetica Neue", Arial, sans-serif;
|
|
display: flex;
|
|
justify-content: center;
|
|
align-items: center;
|
|
min-height: 100vh;
|
|
margin: 0;
|
|
background: #f5f5f5;
|
|
padding: 20px;
|
|
}
|
|
.card {
|
|
height: 600px;
|
|
width: 480px;
|
|
border-radius: 16px;
|
|
background: #ffffff;
|
|
box-sizing: border-box;
|
|
border: 1px solid #f2f2f6;
|
|
box-shadow: 0px 2px 4px 0px rgba(0, 0, 0, 0.12);
|
|
padding: 32px 24px 24px;
|
|
text-align: center;
|
|
display: flex;
|
|
justify-content: center;
|
|
align-items: center;
|
|
flex-direction: column;
|
|
}
|
|
.lock-icon {
|
|
width: 120px;
|
|
height: 120px;
|
|
margin: 0 auto;
|
|
object-fit: contain;
|
|
display: block;
|
|
}
|
|
h1 {
|
|
margin: 8px 0 0;
|
|
font-family:
|
|
"PingFang SC",
|
|
-apple-system,
|
|
BlinkMacSystemFont,
|
|
"Segoe UI",
|
|
Roboto,
|
|
"Helvetica Neue",
|
|
Arial,
|
|
sans-serif;
|
|
font-size: 18px;
|
|
font-weight: 600;
|
|
line-height: 44px;
|
|
text-align: center;
|
|
letter-spacing: normal;
|
|
color: #181c1f;
|
|
}
|
|
p {
|
|
margin: 0;
|
|
font-family:
|
|
"PingFang SC",
|
|
-apple-system,
|
|
BlinkMacSystemFont,
|
|
"Segoe UI",
|
|
Roboto,
|
|
"Helvetica Neue",
|
|
Arial,
|
|
sans-serif;
|
|
font-size: 14px;
|
|
font-weight: normal;
|
|
line-height: 21px;
|
|
text-align: center;
|
|
letter-spacing: normal;
|
|
color: rgba(24, 28, 31, 0.6);
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="card">
|
|
<img
|
|
class="lock-icon"
|
|
src="https://img.alicdn.com/imgextra/i4/O1CN01fS3xxz1vbzZSGjbe0_!!6000000006192-2-tps-480-480.png"
|
|
alt="lock icon"
|
|
/>
|
|
<h1>授权成功</h1>
|
|
<p>请返回终端继续操作。此页面可以关闭。</p>
|
|
</div>
|
|
</body>
|
|
</html>`
|
|
|
|
const notEnabledHTML = `<!doctype html>
|
|
<html lang="zh-CN">
|
|
<head>
|
|
<meta charset="utf-8" />
|
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
<title>钉钉 CLI</title>
|
|
<style>
|
|
* {
|
|
margin: 0;
|
|
padding: 0;
|
|
box-sizing: border-box;
|
|
}
|
|
body {
|
|
font-family:
|
|
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
|
|
"Helvetica Neue", Arial, sans-serif;
|
|
display: flex;
|
|
justify-content: center;
|
|
align-items: center;
|
|
min-height: 100vh;
|
|
background: #fff;
|
|
padding: 20px;
|
|
}
|
|
.container {
|
|
text-align: center;
|
|
width: 100%;
|
|
max-width: 400px;
|
|
border-radius: 16px;
|
|
background: #ffffff;
|
|
border: 1px solid #f2f2f6;
|
|
box-shadow: 0 2px 4px 0 rgba(0, 0, 0, 0.12);
|
|
padding: 32px 24px 24px;
|
|
}
|
|
.lock-icon {
|
|
width: 120px;
|
|
height: 120px;
|
|
margin: 0 auto;
|
|
object-fit: contain;
|
|
display: block;
|
|
}
|
|
h1 {
|
|
font-family:
|
|
"PingFang SC",
|
|
-apple-system,
|
|
BlinkMacSystemFont,
|
|
"Segoe UI",
|
|
Roboto,
|
|
"Helvetica Neue",
|
|
Arial,
|
|
sans-serif;
|
|
font-size: 18px;
|
|
font-weight: 600;
|
|
line-height: 44px;
|
|
text-align: center;
|
|
color: #181c1f;
|
|
}
|
|
p {
|
|
font-family:
|
|
"PingFang SC",
|
|
-apple-system,
|
|
BlinkMacSystemFont,
|
|
"Segoe UI",
|
|
Roboto,
|
|
"Helvetica Neue",
|
|
Arial,
|
|
sans-serif;
|
|
font-size: 14px;
|
|
font-weight: 400;
|
|
line-height: 21px;
|
|
text-align: center;
|
|
color: rgba(24, 28, 31, 0.6);
|
|
margin-bottom: 24px;
|
|
}
|
|
.form-group {
|
|
text-align: left;
|
|
margin-bottom: 24px;
|
|
}
|
|
.form-label {
|
|
font-family:
|
|
"PingFang SC",
|
|
-apple-system,
|
|
BlinkMacSystemFont,
|
|
"Segoe UI",
|
|
Roboto,
|
|
"Helvetica Neue",
|
|
Arial,
|
|
sans-serif;
|
|
font-size: 14px;
|
|
font-weight: 400;
|
|
line-height: 14px;
|
|
color: rgba(24, 28, 31, 0.6);
|
|
margin-top: 38px;
|
|
margin-bottom: 8px;
|
|
display: block;
|
|
}
|
|
.select-wrapper {
|
|
position: relative;
|
|
}
|
|
.custom-select-trigger {
|
|
width: 100%;
|
|
height: 40px;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
padding: 0 16px;
|
|
gap: 12px;
|
|
border-radius: 8px;
|
|
background: #ffffff;
|
|
border: 1px solid rgba(126, 134, 142, 0.24);
|
|
cursor: pointer;
|
|
color: #181c1f;
|
|
text-align: left;
|
|
}
|
|
.custom-select-text {
|
|
flex: 1;
|
|
font-size: 14px;
|
|
line-height: 24px;
|
|
color: rgba(24, 28, 31, 0.48);
|
|
}
|
|
.custom-select.has-value .custom-select-text {
|
|
color: #181c1f;
|
|
}
|
|
.custom-select-arrow {
|
|
width: 16px;
|
|
height: 16px;
|
|
flex-shrink: 0;
|
|
background-image: url("https://img.alicdn.com/imgextra/i1/O1CN01MzGSB21oZ3iyQ8H5e_!!6000000005238-55-tps-16-16.svg");
|
|
background-repeat: no-repeat;
|
|
background-size: 16px 16px;
|
|
background-position: center;
|
|
opacity: 0.5;
|
|
}
|
|
.custom-select-menu {
|
|
position: absolute;
|
|
left: 0;
|
|
right: 0;
|
|
top: calc(100% + 8px);
|
|
background: #ffffff;
|
|
border-radius: 10px;
|
|
padding: 8px 12px;
|
|
list-style: none;
|
|
margin: 0;
|
|
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.12);
|
|
display: none;
|
|
z-index: 20;
|
|
}
|
|
.custom-select.open .custom-select-menu {
|
|
display: block;
|
|
}
|
|
.custom-select-option {
|
|
width: 100%;
|
|
height: 40px;
|
|
border: none;
|
|
background: transparent;
|
|
text-align: left;
|
|
padding: 8px 12px;
|
|
border-radius: 8px;
|
|
font-size: 14px;
|
|
line-height: 24px;
|
|
color: #181c1f;
|
|
cursor: pointer;
|
|
}
|
|
.custom-select-option:hover {
|
|
background: rgba(126, 134, 142, 0.16);
|
|
}
|
|
.custom-select-option.is-active {
|
|
background: #e8eaee;
|
|
}
|
|
.btn {
|
|
width: 100%;
|
|
height: 40px;
|
|
border-radius: 1000px;
|
|
background: #0066ff;
|
|
border: none;
|
|
cursor: pointer;
|
|
font-family:
|
|
"PingFang SC",
|
|
-apple-system,
|
|
BlinkMacSystemFont,
|
|
"Segoe UI",
|
|
Roboto,
|
|
"Helvetica Neue",
|
|
Arial,
|
|
sans-serif;
|
|
font-size: 16px;
|
|
font-weight: 500;
|
|
line-height: 24px;
|
|
color: #ffffff;
|
|
}
|
|
.btn:hover {
|
|
background: #0b6cff;
|
|
}
|
|
.btn:disabled {
|
|
background: #d9d9d9;
|
|
cursor: not-allowed;
|
|
}
|
|
.link {
|
|
color: #1890ff;
|
|
font-size: 14px;
|
|
text-decoration: none;
|
|
margin-top: 16px;
|
|
display: inline-block;
|
|
}
|
|
.success-msg {
|
|
display: none;
|
|
width: 100%;
|
|
height: 60px;
|
|
gap: 12px;
|
|
padding: 16px 20px;
|
|
margin-top: 50px;
|
|
margin-bottom: 16px;
|
|
background: #eaf1ff;
|
|
border-radius: 12px;
|
|
align-items: center;
|
|
}
|
|
.success-msg-icon {
|
|
width: 24px;
|
|
height: 24px;
|
|
flex-shrink: 0;
|
|
}
|
|
.success-msg-text {
|
|
flex: 1;
|
|
font-size: 14px;
|
|
line-height: 22px;
|
|
color: #181c1f;
|
|
}
|
|
.error-msg {
|
|
color: #ff4d4f;
|
|
font-size: 14px;
|
|
margin-top: 8px;
|
|
display: none;
|
|
}
|
|
.loading {
|
|
display: inline-block;
|
|
width: 16px;
|
|
height: 16px;
|
|
border: 2px solid #fff;
|
|
border-top-color: transparent;
|
|
border-radius: 50%;
|
|
animation: spin 0.8s linear infinite;
|
|
margin-right: 8px;
|
|
vertical-align: middle;
|
|
}
|
|
@keyframes spin {
|
|
to {
|
|
transform: rotate(360deg);
|
|
}
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="container">
|
|
<img
|
|
class="lock-icon"
|
|
src="https://img.alicdn.com/imgextra/i4/O1CN01fS3xxz1vbzZSGjbe0_!!6000000006192-2-tps-480-480.png"
|
|
alt="lock icon"
|
|
/>
|
|
<h1>该组织尚未开启 CLI 数据访问权限</h1>
|
|
<p>
|
|
你所选择的组织管理员尚未开启<br />「允许成员通过 CLI
|
|
访问其个人数据」的权限。
|
|
</p>
|
|
|
|
<div class="form-group">
|
|
<label class="form-label">选择一位主管理员发送开通申请</label>
|
|
<div class="select-wrapper">
|
|
<div class="custom-select" id="adminSelect">
|
|
<button
|
|
type="button"
|
|
class="custom-select-trigger"
|
|
aria-haspopup="listbox"
|
|
aria-expanded="false"
|
|
>
|
|
<span class="custom-select-text">加载中...</span>
|
|
<span class="custom-select-arrow"></span>
|
|
</button>
|
|
<ul class="custom-select-menu" role="listbox" id="adminMenu"></ul>
|
|
<input type="hidden" name="adminStaffId" value="" />
|
|
</div>
|
|
</div>
|
|
<div id="errorMsg" class="error-msg"></div>
|
|
</div>
|
|
|
|
<div id="successMsg" class="success-msg">
|
|
<svg
|
|
class="success-msg-icon"
|
|
viewBox="0 0 16 16"
|
|
fill="none"
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
>
|
|
<path
|
|
d="M8 1.33333C4.32 1.33333 1.33333 4.32 1.33333 8C1.33333 11.68 4.32 14.6667 8 14.6667C11.68 14.6667 14.6667 11.68 14.6667 8C14.6667 4.32 11.68 1.33333 8 1.33333ZM8 13.3333C5.05333 13.3333 2.66667 10.9467 2.66667 8C2.66667 5.05333 5.05333 2.66667 8 2.66667C10.9467 2.66667 13.3333 5.05333 13.3333 8C13.3333 10.9467 10.9467 13.3333 8 13.3333ZM7.33333 9.33333H8.66667V10.6667H7.33333V9.33333ZM7.33333 5.33333H8.66667V8H7.33333V5.33333Z"
|
|
fill="#0066FF"
|
|
/>
|
|
</svg>
|
|
<span class="success-msg-text"
|
|
>已向管理员发送权限申请,正在等待审核<br />审核通过后,请返回终端继续操作</span
|
|
>
|
|
</div>
|
|
|
|
<button id="applyBtn" class="btn" disabled>立即申请</button>
|
|
<a id="backLink" class="link" href="#">返回选择其他组织</a>
|
|
</div>
|
|
|
|
<script>
|
|
const adminSelect = document.getElementById("adminSelect");
|
|
const trigger = adminSelect.querySelector(".custom-select-trigger");
|
|
const text = adminSelect.querySelector(".custom-select-text");
|
|
const menu = document.getElementById("adminMenu");
|
|
const hiddenInput = adminSelect.querySelector('input[name="adminStaffId"]');
|
|
const btn = document.getElementById("applyBtn");
|
|
const successMsg = document.getElementById("successMsg");
|
|
const errorMsg = document.getElementById("errorMsg");
|
|
const backLink = document.getElementById("backLink");
|
|
|
|
let admins = [];
|
|
let clientId = "";
|
|
let applySent = false;
|
|
let selectedAdminId = "";
|
|
let pollTimer = null;
|
|
|
|
function closeMenu() {
|
|
adminSelect.classList.remove("open");
|
|
trigger.setAttribute("aria-expanded", "false");
|
|
}
|
|
|
|
function openMenu() {
|
|
adminSelect.classList.add("open");
|
|
trigger.setAttribute("aria-expanded", "true");
|
|
}
|
|
|
|
function showError(msg) {
|
|
errorMsg.textContent = msg;
|
|
errorMsg.style.display = "block";
|
|
}
|
|
|
|
function hideError() {
|
|
errorMsg.style.display = "none";
|
|
}
|
|
|
|
function setSelected(staffId, name) {
|
|
hiddenInput.value = staffId || "";
|
|
text.textContent = name || "请选择";
|
|
if (staffId) {
|
|
adminSelect.classList.add("has-value");
|
|
} else {
|
|
adminSelect.classList.remove("has-value");
|
|
}
|
|
btn.disabled = applySent || !staffId;
|
|
}
|
|
|
|
function renderAdminOptions(list) {
|
|
menu.innerHTML = "";
|
|
list.forEach(function (admin) {
|
|
const li = document.createElement("li");
|
|
const option = document.createElement("button");
|
|
option.type = "button";
|
|
option.className = "custom-select-option";
|
|
option.setAttribute("data-value", admin.staffId);
|
|
option.textContent = admin.name;
|
|
|
|
if (selectedAdminId && admin.staffId === selectedAdminId) {
|
|
option.classList.add("is-active");
|
|
}
|
|
|
|
option.addEventListener("click", function () {
|
|
selectedAdminId = admin.staffId;
|
|
const all = menu.querySelectorAll(".custom-select-option");
|
|
all.forEach(function (item) {
|
|
item.classList.remove("is-active");
|
|
});
|
|
option.classList.add("is-active");
|
|
setSelected(admin.staffId, admin.name);
|
|
closeMenu();
|
|
hideError();
|
|
});
|
|
|
|
li.appendChild(option);
|
|
menu.appendChild(li);
|
|
});
|
|
}
|
|
|
|
function setAppliedState() {
|
|
btn.disabled = true;
|
|
btn.textContent = "立即申请";
|
|
trigger.disabled = true;
|
|
adminSelect.classList.remove("open");
|
|
successMsg.style.display = "flex";
|
|
backLink.style.pointerEvents = "none";
|
|
backLink.style.color = "#999";
|
|
backLink.onclick = function (e) {
|
|
e.preventDefault();
|
|
return false;
|
|
};
|
|
startPolling();
|
|
}
|
|
|
|
function startPolling() {
|
|
if (pollTimer) return;
|
|
pollTimer = setInterval(checkAuthStatus, 5000);
|
|
checkAuthStatus();
|
|
}
|
|
|
|
function stopPolling() {
|
|
if (pollTimer) {
|
|
clearInterval(pollTimer);
|
|
pollTimer = null;
|
|
}
|
|
}
|
|
|
|
async function checkAuthStatus() {
|
|
try {
|
|
const res = await fetch("/api/cliAuthEnabled");
|
|
const data = await res.json();
|
|
if (data.success && data.result && data.result.cliAuthEnabled) {
|
|
stopPolling();
|
|
location.href = "/success";
|
|
}
|
|
} catch (e) {
|
|
console.error("Poll error", e);
|
|
}
|
|
}
|
|
|
|
async function loadAdmins() {
|
|
try {
|
|
const res = await fetch("/api/superAdmin");
|
|
const data = await res.json();
|
|
if (data.success && data.result && data.result.length > 0) {
|
|
admins = data.result;
|
|
renderAdminOptions(admins);
|
|
|
|
if (selectedAdminId) {
|
|
const selected = admins.find(function (a) {
|
|
return a.staffId === selectedAdminId;
|
|
});
|
|
if (selected) {
|
|
setSelected(selected.staffId, selected.name);
|
|
} else {
|
|
setSelected("", "请选择");
|
|
}
|
|
} else {
|
|
setSelected("", "请选择");
|
|
}
|
|
} else {
|
|
setSelected("", "暂无可选管理员");
|
|
trigger.disabled = true;
|
|
showError((data && data.errorMsg) || "获取管理员列表失败");
|
|
}
|
|
} catch (e) {
|
|
setSelected("", "加载失败");
|
|
trigger.disabled = true;
|
|
showError("网络错误,请重试");
|
|
}
|
|
}
|
|
|
|
async function init() {
|
|
try {
|
|
const statusRes = await fetch("/api/status");
|
|
const status = await statusRes.json();
|
|
clientId = status.clientId || "";
|
|
applySent = status.applySent || false;
|
|
selectedAdminId = status.selectedAdminId || "";
|
|
const authorizeUrl = status.authorizeUrl || "";
|
|
|
|
if (clientId && authorizeUrl) {
|
|
const port = location.port;
|
|
const redirectUri = encodeURIComponent(
|
|
"http://127.0.0.1:" + port + "/callback"
|
|
);
|
|
backLink.href =
|
|
authorizeUrl + "?client_id=" +
|
|
clientId +
|
|
"&prompt=consent&redirect_uri=" +
|
|
redirectUri +
|
|
"&response_type=code&scope=openid+corpid";
|
|
}
|
|
|
|
if (applySent) {
|
|
setAppliedState();
|
|
}
|
|
} catch (e) {
|
|
console.error("Failed to load status", e);
|
|
}
|
|
|
|
await loadAdmins();
|
|
}
|
|
|
|
trigger.addEventListener("click", function () {
|
|
if (trigger.disabled) return;
|
|
if (adminSelect.classList.contains("open")) {
|
|
closeMenu();
|
|
} else {
|
|
openMenu();
|
|
}
|
|
});
|
|
|
|
document.addEventListener("click", function (event) {
|
|
if (!adminSelect.contains(event.target)) {
|
|
closeMenu();
|
|
}
|
|
});
|
|
|
|
btn.onclick = async function () {
|
|
const value = hiddenInput.value;
|
|
if (!value) return;
|
|
|
|
btn.disabled = true;
|
|
btn.innerHTML = '<span class="loading"></span>申请中...';
|
|
hideError();
|
|
try {
|
|
const res = await fetch(
|
|
"/api/sendApply?adminStaffId=" + encodeURIComponent(value)
|
|
);
|
|
const data = await res.json();
|
|
if (data.success && data.result) {
|
|
setAppliedState();
|
|
} else {
|
|
showError(data.errorMsg || "申请失败,请重试");
|
|
btn.disabled = false;
|
|
btn.textContent = "立即申请";
|
|
}
|
|
} catch (e) {
|
|
showError("网络错误,请重试");
|
|
btn.disabled = false;
|
|
btn.textContent = "立即申请";
|
|
}
|
|
};
|
|
|
|
init();
|
|
</script>
|
|
</body>
|
|
</html>`
|
|
|
|
const accessDeniedHTML = `<!doctype html>
|
|
<html lang="zh-CN">
|
|
<head>
|
|
<meta charset="utf-8" />
|
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
<title>钉钉 CLI</title>
|
|
<style>
|
|
body {
|
|
font-family:
|
|
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
|
|
"Helvetica Neue", Arial, sans-serif;
|
|
display: flex;
|
|
justify-content: center;
|
|
align-items: center;
|
|
min-height: 100vh;
|
|
margin: 0;
|
|
background: #f5f5f5;
|
|
padding: 20px;
|
|
}
|
|
.card {
|
|
height: 600px;
|
|
width: 480px;
|
|
border-radius: 16px;
|
|
background: #ffffff;
|
|
box-sizing: border-box;
|
|
border: 1px solid #f2f2f6;
|
|
box-shadow: 0px 2px 4px 0px rgba(0, 0, 0, 0.12);
|
|
padding: 32px 24px 24px;
|
|
text-align: center;
|
|
display: flex;
|
|
justify-content: center;
|
|
align-items: center;
|
|
flex-direction: column;
|
|
}
|
|
.lock-icon {
|
|
width: 120px;
|
|
height: 120px;
|
|
margin: 0 auto;
|
|
object-fit: contain;
|
|
display: block;
|
|
}
|
|
h1 {
|
|
margin: 8px 0 0;
|
|
font-family:
|
|
"PingFang SC",
|
|
-apple-system,
|
|
BlinkMacSystemFont,
|
|
"Segoe UI",
|
|
Roboto,
|
|
"Helvetica Neue",
|
|
Arial,
|
|
sans-serif;
|
|
font-size: 18px;
|
|
font-weight: 600;
|
|
line-height: 44px;
|
|
text-align: center;
|
|
letter-spacing: normal;
|
|
color: #181c1f;
|
|
}
|
|
p {
|
|
margin: 0;
|
|
font-family:
|
|
"PingFang SC",
|
|
-apple-system,
|
|
BlinkMacSystemFont,
|
|
"Segoe UI",
|
|
Roboto,
|
|
"Helvetica Neue",
|
|
Arial,
|
|
sans-serif;
|
|
font-size: 14px;
|
|
font-weight: normal;
|
|
line-height: 21px;
|
|
text-align: center;
|
|
letter-spacing: normal;
|
|
color: rgba(24, 28, 31, 0.6);
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="card">
|
|
<img
|
|
class="lock-icon"
|
|
src="https://img.alicdn.com/imgextra/i4/O1CN01fS3xxz1vbzZSGjbe0_!!6000000006192-2-tps-480-480.png"
|
|
alt="lock icon"
|
|
/>
|
|
<h1>无权限访问</h1>
|
|
<p>您不在该组织的 CLI 授权人员范围内。请联系组织管理员将您加入授权名单。此页面可以关闭。</p>
|
|
</div>
|
|
</body>
|
|
</html>`
|
|
|
|
const channelDeniedHTML = `<!doctype html>
|
|
<html lang="zh-CN">
|
|
<head>
|
|
<meta charset="utf-8" />
|
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
<title>钉钉 CLI</title>
|
|
<style>
|
|
body {
|
|
font-family:
|
|
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
|
|
"Helvetica Neue", Arial, sans-serif;
|
|
display: flex;
|
|
justify-content: center;
|
|
align-items: center;
|
|
min-height: 100vh;
|
|
margin: 0;
|
|
background: #f5f5f5;
|
|
padding: 20px;
|
|
}
|
|
.card {
|
|
height: 600px;
|
|
width: 480px;
|
|
border-radius: 16px;
|
|
background: #ffffff;
|
|
box-sizing: border-box;
|
|
border: 1px solid #f2f2f6;
|
|
box-shadow: 0px 2px 4px 0px rgba(0, 0, 0, 0.12);
|
|
padding: 32px 24px 24px;
|
|
text-align: center;
|
|
display: flex;
|
|
justify-content: center;
|
|
align-items: center;
|
|
flex-direction: column;
|
|
}
|
|
.lock-icon {
|
|
width: 120px;
|
|
height: 120px;
|
|
margin: 0 auto;
|
|
object-fit: contain;
|
|
display: block;
|
|
}
|
|
h1 {
|
|
margin: 8px 0 0;
|
|
font-family:
|
|
"PingFang SC",
|
|
-apple-system,
|
|
BlinkMacSystemFont,
|
|
"Segoe UI",
|
|
Roboto,
|
|
"Helvetica Neue",
|
|
Arial,
|
|
sans-serif;
|
|
font-size: 18px;
|
|
font-weight: 600;
|
|
line-height: 44px;
|
|
text-align: center;
|
|
letter-spacing: normal;
|
|
color: #181c1f;
|
|
}
|
|
p {
|
|
margin: 0;
|
|
font-family:
|
|
"PingFang SC",
|
|
-apple-system,
|
|
BlinkMacSystemFont,
|
|
"Segoe UI",
|
|
Roboto,
|
|
"Helvetica Neue",
|
|
Arial,
|
|
sans-serif;
|
|
font-size: 14px;
|
|
font-weight: normal;
|
|
line-height: 21px;
|
|
text-align: center;
|
|
letter-spacing: normal;
|
|
color: rgba(24, 28, 31, 0.6);
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="card">
|
|
<img
|
|
class="lock-icon"
|
|
src="https://img.alicdn.com/imgextra/i4/O1CN01fS3xxz1vbzZSGjbe0_!!6000000006192-2-tps-480-480.png"
|
|
alt="lock icon"
|
|
/>
|
|
<h1>渠道未授权</h1>
|
|
<p>当前渠道未获得该组织授权,或组织已开启渠道管控。请联系组织管理员开通渠道访问权限,或升级到最新版本的 CLI。此页面可以关闭。</p>
|
|
</div>
|
|
</body>
|
|
</html>`
|
|
|
|
const enterpriseDeniedHTML = `<!doctype html>
|
|
<html lang="zh-CN">
|
|
<head>
|
|
<meta charset="utf-8" />
|
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
<title>钉钉 CLI</title>
|
|
<style>
|
|
body {
|
|
font-family:
|
|
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
|
|
"Helvetica Neue", Arial, sans-serif;
|
|
display: flex;
|
|
justify-content: center;
|
|
align-items: center;
|
|
min-height: 100vh;
|
|
margin: 0;
|
|
background: #f5f5f5;
|
|
padding: 20px;
|
|
}
|
|
.card {
|
|
height: 600px;
|
|
width: 480px;
|
|
border-radius: 16px;
|
|
background: #ffffff;
|
|
box-sizing: border-box;
|
|
border: 1px solid #f2f2f6;
|
|
box-shadow: 0px 2px 4px 0px rgba(0, 0, 0, 0.12);
|
|
padding: 32px 24px 24px;
|
|
text-align: center;
|
|
display: flex;
|
|
justify-content: center;
|
|
align-items: center;
|
|
flex-direction: column;
|
|
}
|
|
.lock-icon {
|
|
width: 120px;
|
|
height: 120px;
|
|
margin: 0 auto;
|
|
object-fit: contain;
|
|
display: block;
|
|
}
|
|
h1 {
|
|
margin: 8px 0 0;
|
|
font-family:
|
|
"PingFang SC",
|
|
-apple-system,
|
|
BlinkMacSystemFont,
|
|
"Segoe UI",
|
|
Roboto,
|
|
"Helvetica Neue",
|
|
Arial,
|
|
sans-serif;
|
|
font-size: 18px;
|
|
font-weight: 600;
|
|
line-height: 44px;
|
|
text-align: center;
|
|
letter-spacing: normal;
|
|
color: #181c1f;
|
|
}
|
|
p {
|
|
margin: 0;
|
|
font-family:
|
|
"PingFang SC",
|
|
-apple-system,
|
|
BlinkMacSystemFont,
|
|
"Segoe UI",
|
|
Roboto,
|
|
"Helvetica Neue",
|
|
Arial,
|
|
sans-serif;
|
|
font-size: 14px;
|
|
font-weight: normal;
|
|
line-height: 21px;
|
|
text-align: center;
|
|
letter-spacing: normal;
|
|
color: rgba(24, 28, 31, 0.6);
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="card">
|
|
<img
|
|
class="lock-icon"
|
|
src="https://img.alicdn.com/imgextra/i4/O1CN01fS3xxz1vbzZSGjbe0_!!6000000006192-2-tps-480-480.png"
|
|
alt="lock icon"
|
|
/>
|
|
<h1>企业安全认证未通过</h1>
|
|
<p>__ENTERPRISE_DENIED_MSG__</p>
|
|
</div>
|
|
</body>
|
|
</html>`
|
|
|
|
// defaultEnterpriseDeniedMsg is shown when the server returns no errorMsg.
|
|
const defaultEnterpriseDeniedMsg = "本次请求未通过企业安全认证"
|
|
|
|
// renderEnterpriseDeniedHTML injects the server-provided denial message (falling
|
|
// back to the default text) into the enterprise-denied page. The message is
|
|
// HTML-escaped before insertion.
|
|
func renderEnterpriseDeniedHTML(serverMsg string) string {
|
|
msg := strings.TrimSpace(serverMsg)
|
|
if msg == "" {
|
|
msg = defaultEnterpriseDeniedMsg
|
|
}
|
|
return strings.ReplaceAll(enterpriseDeniedHTML, "__ENTERPRISE_DENIED_MSG__", html.EscapeString(msg)+" 此页面可以关闭。")
|
|
}
|
|
|
|
// CLIAuthStatus represents the response from /cli/cliAuthEnabled API.
|
|
type CLIAuthStatus struct {
|
|
Success bool `json:"success"`
|
|
ErrorCode string `json:"errorCode,omitempty"`
|
|
ErrorMsg string `json:"errorMsg,omitempty"`
|
|
Result *CLIAuthResult `json:"result"`
|
|
}
|
|
|
|
// CLIAuthResult holds the business data returned by /cli/cliAuthEnabled.
|
|
// The server computes cliAuthEnabled by considering the org switch, userScope,
|
|
// and channelScope together; the CLI uses it as-is.
|
|
type CLIAuthResult struct {
|
|
CLIAuthEnabled bool `json:"cliAuthEnabled"`
|
|
UserScope string `json:"userScope,omitempty"` // "all" | "specified" | "forbidden"
|
|
AllowedUsers []string `json:"allowedUsers,omitempty"` // staffId list when userScope="specified"
|
|
ChannelScope string `json:"channelScope,omitempty"` // "all" | "specified"
|
|
AllowedChannels []string `json:"allowedChannels,omitempty"` // channelCode list when channelScope="specified"
|
|
ChannelConfigEnabled bool `json:"channelConfigEnabled,omitempty"` // whether org has any channel restriction configured
|
|
}
|
|
|
|
// classifyDenialReason inspects a CLIAuthStatus response and returns a machine-readable
|
|
// denial reason string. Returns "" when access is granted.
|
|
//
|
|
// Priority rationale:
|
|
// 1. Explicit org-wide ban (userScope=forbidden) always wins.
|
|
// 2. Channel scope is evaluated BEFORE user scope because the CLI has
|
|
// authoritative knowledge of DWS_CHANNEL and can verify membership against
|
|
// allowedChannels. This avoids falsely blaming the user when the real
|
|
// denial cause is a channel mismatch (e.g. user is in allowedUsers but the
|
|
// current channel is not in allowedChannels).
|
|
// 3. Only when the channel is unrestricted or matches do we attribute the
|
|
// denial to the user scope.
|
|
func classifyDenialReason(status *CLIAuthStatus, currentChannel string) string {
|
|
if status.ErrorCode == "CHANNEL_REQUIRED" {
|
|
return "channel_required"
|
|
}
|
|
if status.ErrorCode == "ENTERPRISE_NOT_AUTHORIZED" {
|
|
return "enterprise_not_authorized"
|
|
}
|
|
if status.ErrorCode == "NO_AUTH" {
|
|
return "no_auth"
|
|
}
|
|
if status.Result == nil || !status.Success {
|
|
return "unknown"
|
|
}
|
|
r := status.Result
|
|
if r.CLIAuthEnabled {
|
|
return ""
|
|
}
|
|
|
|
if r.UserScope == "forbidden" {
|
|
return "user_forbidden"
|
|
}
|
|
|
|
if r.ChannelScope == "specified" {
|
|
if currentChannel == "" {
|
|
return "channel_required"
|
|
}
|
|
if !slices.Contains(r.AllowedChannels, currentChannel) {
|
|
return "channel_not_allowed"
|
|
}
|
|
}
|
|
|
|
if r.UserScope == "specified" {
|
|
return "user_not_allowed"
|
|
}
|
|
return "cli_not_enabled"
|
|
}
|
|
|
|
// SuperAdmin represents a corp super admin.
|
|
type SuperAdmin struct {
|
|
StaffID string `json:"staffId"`
|
|
Name string `json:"name"`
|
|
}
|
|
|
|
// SuperAdminResponse represents the response from /cli/superAdmin API.
|
|
type SuperAdminResponse struct {
|
|
Success bool `json:"success"`
|
|
ErrorCode string `json:"errorCode,omitempty"`
|
|
ErrorMsg string `json:"errorMsg,omitempty"`
|
|
Result []SuperAdmin `json:"result"`
|
|
}
|
|
|
|
// SendApplyResponse represents the response from /cli/sendCliAuthApply API.
|
|
type SendApplyResponse struct {
|
|
Success bool `json:"success"`
|
|
ErrorCode string `json:"errorCode,omitempty"`
|
|
ErrorMsg string `json:"errorMsg,omitempty"`
|
|
Result bool `json:"result"`
|
|
}
|
|
|
|
// mcpRequestMaxRetries is the maximum number of attempts for MCP API calls
|
|
// (e.g. /cli/cliAuthEnabled, /cli/clientId, /cli/superAdmin, /cli/sendCliAuthApply)
|
|
// to tolerate transient network errors before propagating the failure.
|
|
const mcpRequestMaxRetries = 3
|
|
|
|
// CheckCLIAuthEnabled checks if CLI authorization is enabled for the current corp.
|
|
// It retries up to mcpRequestMaxRetries times on transient errors to avoid
|
|
// false negatives caused by momentary network issues.
|
|
func (p *OAuthProvider) CheckCLIAuthEnabled(ctx context.Context, accessToken string) (*CLIAuthStatus, error) {
|
|
var lastErr error
|
|
traceID := cliAuthTraceID()
|
|
for attempt := 0; attempt < mcpRequestMaxRetries; attempt++ {
|
|
if attempt > 0 {
|
|
select {
|
|
case <-ctx.Done():
|
|
return nil, ctx.Err()
|
|
case <-oauthRetryAfter(time.Duration(attempt) * time.Second):
|
|
}
|
|
}
|
|
status, err := p.doCheckCLIAuthEnabledAttempt(ctx, accessToken, attempt+1, traceID)
|
|
if err == nil {
|
|
return status, nil
|
|
}
|
|
lastErr = err
|
|
}
|
|
return nil, fmt.Errorf("check CLI auth status failed after %d attempts: %w", mcpRequestMaxRetries, lastErr)
|
|
}
|
|
|
|
func (p *OAuthProvider) doCheckCLIAuthEnabled(ctx context.Context, accessToken string) (*CLIAuthStatus, error) {
|
|
return p.doCheckCLIAuthEnabledAttempt(ctx, accessToken, 1, cliAuthTraceID())
|
|
}
|
|
|
|
func (p *OAuthProvider) doCheckCLIAuthEnabledAttempt(ctx context.Context, accessToken string, attempt int, traceID string) (*CLIAuthStatus, error) {
|
|
url := MCPBaseURLForLoginRegion(p.loginRegion()) + CLIAuthEnabledPath
|
|
req, err := oauthNewRequest(ctx, http.MethodGet, url, nil)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("creating request: %w", err)
|
|
}
|
|
req.Header.Set("x-user-access-token", accessToken)
|
|
applyCLIAuthTraceHeaders(req, traceID)
|
|
if ch := os.Getenv("DWS_CHANNEL"); ch != "" {
|
|
req.Header.Set("x-dws-channel", ch)
|
|
}
|
|
applyEditionEnterpriseCredentialHeaders(req)
|
|
slog.Debug("auth.cli_auth_enabled.request",
|
|
"attempt", attempt,
|
|
"url", url,
|
|
"trace_id", traceID,
|
|
"channel", os.Getenv("DWS_CHANNEL"),
|
|
)
|
|
|
|
client := p.httpClient
|
|
if client == nil {
|
|
client = oauthHTTPClient
|
|
}
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
slog.Debug("auth.cli_auth_enabled.error",
|
|
"attempt", attempt,
|
|
"url", url,
|
|
"trace_id", traceID,
|
|
"error", err,
|
|
)
|
|
return nil, fmt.Errorf("sending request: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
slog.Debug("auth.cli_auth_enabled.response",
|
|
"attempt", attempt,
|
|
"url", url,
|
|
"status", resp.StatusCode,
|
|
"trace_id", traceID,
|
|
"response_trace_id", cliAuthResponseTraceID(resp.Header),
|
|
"eagleeye_rpc_id", resp.Header.Get("EagleEye-RpcId"),
|
|
)
|
|
|
|
data, err := io.ReadAll(io.LimitReader(resp.Body, config.MaxResponseBodySize))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("reading response: %w", err)
|
|
}
|
|
|
|
var status CLIAuthStatus
|
|
if err := json.Unmarshal(data, &status); err != nil {
|
|
return nil, fmt.Errorf("parsing response: %w", err)
|
|
}
|
|
return &status, nil
|
|
}
|
|
|
|
func cliAuthTraceID() string {
|
|
if traceID := strings.TrimSpace(os.Getenv("DINGTALK_TRACE_ID")); traceID != "" {
|
|
return traceID
|
|
}
|
|
return strings.ReplaceAll(uuid.NewString(), "-", "")
|
|
}
|
|
|
|
func applyCLIAuthTraceHeaders(req *http.Request, traceID string) {
|
|
if req == nil || traceID == "" {
|
|
return
|
|
}
|
|
req.Header.Set("EagleEye-TraceId", traceID)
|
|
req.Header.Set("X-Dingtalk-Trace-Id", traceID)
|
|
}
|
|
|
|
func cliAuthResponseTraceID(headers http.Header) string {
|
|
for _, key := range []string{
|
|
"EagleEye-TraceId",
|
|
"X-Trace-Id",
|
|
"X-Request-Id",
|
|
"X-Dingtalk-Trace-Id",
|
|
} {
|
|
if value := strings.TrimSpace(headers.Get(key)); value != "" {
|
|
return value
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// GetSuperAdmins fetches the list of corp super admins.
|
|
// It retries up to mcpRequestMaxRetries times on transient errors.
|
|
func GetSuperAdmins(ctx context.Context, accessToken string) (*SuperAdminResponse, error) {
|
|
return GetSuperAdminsForLoginRegion(ctx, accessToken, LoginRegionDefault)
|
|
}
|
|
|
|
func GetSuperAdminsForLoginRegion(ctx context.Context, accessToken string, region LoginRegion) (*SuperAdminResponse, error) {
|
|
var lastErr error
|
|
for attempt := 0; attempt < mcpRequestMaxRetries; attempt++ {
|
|
if attempt > 0 {
|
|
select {
|
|
case <-ctx.Done():
|
|
return nil, ctx.Err()
|
|
case <-oauthRetryAfter(time.Duration(attempt) * time.Second):
|
|
}
|
|
}
|
|
result, err := doGetSuperAdminsForLoginRegion(ctx, accessToken, region)
|
|
if err == nil {
|
|
return result, nil
|
|
}
|
|
lastErr = err
|
|
}
|
|
return nil, fmt.Errorf("get super admins failed after %d attempts: %w", mcpRequestMaxRetries, lastErr)
|
|
}
|
|
|
|
func doGetSuperAdmins(ctx context.Context, accessToken string) (*SuperAdminResponse, error) {
|
|
return doGetSuperAdminsForLoginRegion(ctx, accessToken, LoginRegionDefault)
|
|
}
|
|
|
|
func doGetSuperAdminsForLoginRegion(ctx context.Context, accessToken string, region LoginRegion) (*SuperAdminResponse, error) {
|
|
url := MCPBaseURLForLoginRegion(region) + SuperAdminPath
|
|
req, err := oauthNewRequest(ctx, http.MethodGet, url, nil)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("creating request: %w", err)
|
|
}
|
|
req.Header.Set("x-user-access-token", accessToken)
|
|
applyEditionEnterpriseCredentialHeaders(req)
|
|
|
|
resp, err := oauthHTTPClient.Do(req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("sending request: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
data, err := io.ReadAll(io.LimitReader(resp.Body, config.MaxResponseBodySize))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("reading response: %w", err)
|
|
}
|
|
|
|
var result SuperAdminResponse
|
|
if err := json.Unmarshal(data, &result); err != nil {
|
|
return nil, fmt.Errorf("parsing response: %w", err)
|
|
}
|
|
return &result, nil
|
|
}
|
|
|
|
// SendCliAuthApply sends a CLI auth apply request to the specified admin.
|
|
// It retries up to mcpRequestMaxRetries times on transient errors.
|
|
func SendCliAuthApply(ctx context.Context, accessToken, adminStaffID string) (*SendApplyResponse, error) {
|
|
return SendCliAuthApplyForLoginRegion(ctx, accessToken, adminStaffID, LoginRegionDefault)
|
|
}
|
|
|
|
func SendCliAuthApplyForLoginRegion(ctx context.Context, accessToken, adminStaffID string, region LoginRegion) (*SendApplyResponse, error) {
|
|
var lastErr error
|
|
for attempt := 0; attempt < mcpRequestMaxRetries; attempt++ {
|
|
if attempt > 0 {
|
|
select {
|
|
case <-ctx.Done():
|
|
return nil, ctx.Err()
|
|
case <-oauthRetryAfter(time.Duration(attempt) * time.Second):
|
|
}
|
|
}
|
|
result, err := doSendCliAuthApplyForLoginRegion(ctx, accessToken, adminStaffID, region)
|
|
if err == nil {
|
|
return result, nil
|
|
}
|
|
lastErr = err
|
|
}
|
|
return nil, fmt.Errorf("send CLI auth apply failed after %d attempts: %w", mcpRequestMaxRetries, lastErr)
|
|
}
|
|
|
|
func doSendCliAuthApply(ctx context.Context, accessToken, adminStaffID string) (*SendApplyResponse, error) {
|
|
return doSendCliAuthApplyForLoginRegion(ctx, accessToken, adminStaffID, LoginRegionDefault)
|
|
}
|
|
|
|
func doSendCliAuthApplyForLoginRegion(ctx context.Context, accessToken, adminStaffID string, region LoginRegion) (*SendApplyResponse, error) {
|
|
url := MCPBaseURLForLoginRegion(region) + SendCliAuthApplyPath + "?adminStaffId=" + adminStaffID
|
|
req, err := oauthNewRequest(ctx, http.MethodGet, url, nil)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("creating request: %w", err)
|
|
}
|
|
req.Header.Set("x-user-access-token", accessToken)
|
|
applyEditionEnterpriseCredentialHeaders(req)
|
|
|
|
resp, err := oauthHTTPClient.Do(req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("sending request: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
data, err := io.ReadAll(io.LimitReader(resp.Body, config.MaxResponseBodySize))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("reading response: %w", err)
|
|
}
|
|
|
|
var result SendApplyResponse
|
|
if err := json.Unmarshal(data, &result); err != nil {
|
|
return nil, fmt.Errorf("parsing response: %w", err)
|
|
}
|
|
return &result, nil
|
|
}
|
|
|
|
// ClientIDResponse represents the response from /cli/clientId API.
|
|
type ClientIDResponse struct {
|
|
Success bool `json:"success"`
|
|
ErrorCode string `json:"errorCode,omitempty"`
|
|
ErrorMsg string `json:"errorMsg,omitempty"`
|
|
Result string `json:"result"`
|
|
}
|
|
|
|
// FetchClientIDFromMCP fetches the CLI client ID from MCP server.
|
|
// This is used when no client ID is provided via flags, config, or env vars.
|
|
// It retries up to mcpRequestMaxRetries times on transient errors.
|
|
func FetchClientIDFromMCP(ctx context.Context) (string, error) {
|
|
return FetchClientIDFromMCPForLoginRegion(ctx, LoginRegionDefault)
|
|
}
|
|
|
|
func FetchClientIDFromMCPForLoginRegion(ctx context.Context, region LoginRegion) (string, error) {
|
|
var lastErr error
|
|
for attempt := 0; attempt < mcpRequestMaxRetries; attempt++ {
|
|
if attempt > 0 {
|
|
select {
|
|
case <-ctx.Done():
|
|
return "", ctx.Err()
|
|
case <-oauthRetryAfter(time.Duration(attempt) * time.Second):
|
|
}
|
|
}
|
|
id, err := doFetchClientIDFromMCPForLoginRegion(ctx, region)
|
|
if err == nil {
|
|
return id, nil
|
|
}
|
|
lastErr = err
|
|
}
|
|
return "", fmt.Errorf("fetch client ID failed after %d attempts: %w", mcpRequestMaxRetries, lastErr)
|
|
}
|
|
|
|
func doFetchClientIDFromMCP(ctx context.Context) (string, error) {
|
|
return doFetchClientIDFromMCPForLoginRegion(ctx, LoginRegionDefault)
|
|
}
|
|
|
|
func doFetchClientIDFromMCPForLoginRegion(ctx context.Context, region LoginRegion) (string, error) {
|
|
url := MCPBaseURLForLoginRegion(region) + ClientIDPath
|
|
req, err := oauthNewRequest(ctx, http.MethodGet, url, nil)
|
|
if err != nil {
|
|
return "", fmt.Errorf("creating request: %w", err)
|
|
}
|
|
|
|
resp, err := oauthHTTPClient.Do(req)
|
|
if err != nil {
|
|
return "", fmt.Errorf("sending request: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
data, err := io.ReadAll(io.LimitReader(resp.Body, config.MaxResponseBodySize))
|
|
if err != nil {
|
|
return "", fmt.Errorf("reading response: %w", err)
|
|
}
|
|
|
|
var result ClientIDResponse
|
|
if err := json.Unmarshal(data, &result); err != nil {
|
|
return "", fmt.Errorf("parsing response: %w", err)
|
|
}
|
|
if !result.Success {
|
|
return "", fmt.Errorf("%s: %s", result.ErrorCode, result.ErrorMsg)
|
|
}
|
|
return result.Result, nil
|
|
}
|