Files

420 lines
14 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 (
"os"
"path/filepath"
"strings"
"sync"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/configmeta"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
)
func init() {
configmeta.Register(configmeta.ConfigItem{
Name: "DWS_CLIENT_ID",
Category: configmeta.CategoryAuth,
Description: "OAuth AppKey (DingTalk 应用凭证)",
DefaultValue: "(内置)",
Sensitive: true,
})
configmeta.Register(configmeta.ConfigItem{
Name: "DWS_CLIENT_SECRET",
Category: configmeta.CategoryAuth,
Description: "OAuth AppSecret (DingTalk 应用凭证)",
DefaultValue: "(内置)",
Sensitive: true,
})
configmeta.Register(configmeta.ConfigItem{
Name: "DWS_CHANNEL",
Category: configmeta.CategoryExternal,
Description: "第三方渠道编码 (channelCode),如 Qoderwork",
})
}
const (
// AuthorizeURL is the DingTalk OAuth authorization page.
AuthorizeURL = "https://login.dingtalk.com/oauth2/auth"
// InternationalAuthorizeURL is the DingTalk international OAuth authorization page.
InternationalAuthorizeURL = "https://login.dingtalk.io/oauth2/auth"
// UserAccessTokenURL exchanges an authorization code for user tokens.
UserAccessTokenURL = "https://api.dingtalk.com/v1.0/oauth2/userAccessToken"
// InternationalUserAccessTokenURL exchanges authorization codes for international user tokens.
InternationalUserAccessTokenURL = "https://api.dingtalk.io/v1.0/oauth2/userAccessToken"
// UserInfoURL fetches the authenticated user's profile.
UserInfoURL = "https://api.dingtalk.com/v1.0/contact/users/me"
// DefaultClientID is the CLI's built-in OAuth client ID (DingTalk AppKey).
// TODO: Replace <YOUR_CLIENT_ID> with your actual DingTalk AppKey before building.
DefaultClientID = "<YOUR_CLIENT_ID>"
// DefaultClientSecret is the CLI's built-in OAuth client secret (DingTalk AppSecret).
// TODO: Replace <YOUR_CLIENT_SECRET> with your actual DingTalk AppSecret before building.
DefaultClientSecret = "<YOUR_CLIENT_SECRET>"
// CallbackPath is the localhost callback endpoint for OAuth redirect.
CallbackPath = "/callback"
// DefaultScopes are the OAuth scopes requested by the CLI.
DefaultScopes = "openid corpid"
// Device Authorization Grant (RFC 8628) endpoints.
// DefaultDeviceBaseURL is the login server base URL for device flow.
DefaultDeviceBaseURL = "https://login.dingtalk.com"
// InternationalDeviceBaseURL is the international login server base URL for device flow.
InternationalDeviceBaseURL = "https://login.dingtalk.io"
// DeviceCodePath requests a device_code and user_code.
DeviceCodePath = "/oauth2/device/code.json"
// DeviceTokenPath polls for authorization completion.
DeviceTokenPath = "/oauth2/device/token.json"
// DeviceGrantType is the grant_type value defined by RFC 8628.
DeviceGrantType = "urn:ietf:params:oauth:grant-type:device_code"
// Terminal API base URL for developer settings page.
DefaultTerminalBaseURL = "https://open-dev.dingtalk.com"
// DevicePollPath is the device flow polling path (used with MCP base URL).
DevicePollPath = "/cli/oauth/device/poll"
// DeveloperSettingsPath is the path to the organization developer settings page.
DeveloperSettingsPath = "/fe/old#/developerSettings"
LogoutURL = "https://login.dingtalk.com/oauth2/logout"
LogoutContinueURL = "https://login.dingtalk.com"
// MCP API endpoints for CLI authorization management.
DefaultMCPBaseURL = config.DefaultMCPBaseURL
InternationalMCPBaseURL = "https://mcp.dingtalk.io"
CLIAuthEnabledPath = "/cli/cliAuthEnabled"
SuperAdminPath = "/cli/superAdmin"
SendCliAuthApplyPath = "/cli/sendCliAuthApply"
ClientIDPath = "/cli/clientId"
// MCP OAuth endpoints (used when clientId is fetched from MCP).
MCPOAuthTokenPath = "/oauth2/getToken"
MCPRefreshTokenPath = "/oauth2/refreshToken"
MCPRevokeTokenPath = "/oauth2/revokeToken"
// App-level access token endpoints (for dws api raw calls).
// AppAccessTokenURL is the unified app-level access token endpoint.
// POST with {"appKey":"X","appSecret":"X"} → {"accessToken":"...","expireIn":7200}
AppAccessTokenURL = "https://api.dingtalk.com/v1.0/oauth2/accessToken"
)
type LoginRegion string
const (
LoginRegionDefault LoginRegion = ""
LoginRegionInternational LoginRegion = "international"
)
func (r LoginRegion) IsInternational() bool {
return r == LoginRegionInternational
}
func AuthorizeURLForLoginRegion(region LoginRegion) string {
if override := LoginBaseURLOverride(); override != "" {
return override + "/oauth2/auth"
}
if region.IsInternational() {
return InternationalAuthorizeURL
}
return AuthorizeURL
}
func DeviceBaseURLForLoginRegion(region LoginRegion) string {
if override := LoginBaseURLOverride(); override != "" {
return override
}
if region.IsInternational() {
return InternationalDeviceBaseURL
}
return DefaultDeviceBaseURL
}
func MCPBaseURLForLoginRegion(region LoginRegion) string {
if override := MCPBaseURLOverride(); override != "" {
return override
}
if region.IsInternational() {
return InternationalMCPBaseURL
}
return GetMCPBaseURL()
}
func UserAccessTokenURLForLoginRegion(region LoginRegion) string {
if override := LoginBaseURLOverride(); override != "" {
return override + "/v1.0/oauth2/userAccessToken"
}
if region.IsInternational() {
return InternationalUserAccessTokenURL
}
return UserAccessTokenURL
}
// GetTerminalBaseURL returns the terminal base URL with priority:
// 1. ~/.dws/terminal_url file content (for pre-release environment)
// 2. Default value (https://open-dev.dingtalk.com)
func GetTerminalBaseURL() string {
return config.GetTerminalBaseURL()
}
// GetDeveloperSettingsURL returns the full URL to the organization developer
// settings page, derived from the terminal base URL.
func GetDeveloperSettingsURL() string {
return config.GetDeveloperSettingsURL()
}
// GetMCPBaseURL returns the MCP base URL with priority:
// 1. ~/.dws/mcp_url file content (for pre-release environment)
// 2. Default value (https://mcp.dingtalk.com)
func GetMCPBaseURL() string {
return config.GetMCPBaseURL()
}
// Runtime overrides set via CLI flags (--client-id, --client-secret).
// These take highest priority over environment variables and defaults.
var (
authUserHomeDir = os.UserHomeDir
defaultAuthClientID = DefaultClientID
defaultAuthSecret = DefaultClientSecret
clientMu sync.RWMutex
runtimeClientID string
runtimeClientSecret string
// clientIDFromMCP indicates whether the clientID was fetched from MCP server.
// When true, MCP OAuth endpoints should be used instead of direct DingTalk API.
clientIDFromMCP bool
loginBaseURLMu sync.RWMutex
loginBaseURLOverride string
mcpBaseURLMu sync.RWMutex
mcpBaseURLOverride string
)
// PushLoginBaseURLOverride sets a process-local DingTalk login base URL
// override and returns a restore function.
func PushLoginBaseURLOverride(baseURL string) func() {
loginBaseURLMu.Lock()
previous := loginBaseURLOverride
loginBaseURLOverride = strings.TrimRight(strings.TrimSpace(baseURL), "/")
loginBaseURLMu.Unlock()
return func() {
loginBaseURLMu.Lock()
loginBaseURLOverride = previous
loginBaseURLMu.Unlock()
}
}
func LoginBaseURLOverride() string {
loginBaseURLMu.RLock()
defer loginBaseURLMu.RUnlock()
return loginBaseURLOverride
}
// PushMCPBaseURLOverride sets a process-local MCP base URL override and returns
// a restore function. It is intended for one command invocation, such as
// pre-release smoke testing.
func PushMCPBaseURLOverride(baseURL string) func() {
mcpBaseURLMu.Lock()
previous := mcpBaseURLOverride
mcpBaseURLOverride = strings.TrimRight(strings.TrimSpace(baseURL), "/")
mcpBaseURLMu.Unlock()
return func() {
mcpBaseURLMu.Lock()
mcpBaseURLOverride = previous
mcpBaseURLMu.Unlock()
}
}
func MCPBaseURLOverride() string {
mcpBaseURLMu.RLock()
defer mcpBaseURLMu.RUnlock()
return mcpBaseURLOverride
}
// SetClientIDFromMCP sets the clientID fetched from MCP server and marks it as MCP-sourced.
func SetClientIDFromMCP(id string) {
clientMu.Lock()
defer clientMu.Unlock()
runtimeClientID = id
clientIDFromMCP = true
}
// IsClientIDFromMCP returns true if the current clientID was fetched from MCP server.
func IsClientIDFromMCP() bool {
clientMu.RLock()
defer clientMu.RUnlock()
return clientIDFromMCP || edition.Get().AuthClientFromMCP
}
// GetUserAccessTokenURL returns the appropriate token exchange URL.
// Uses MCP endpoint when clientID is from MCP, otherwise uses direct DingTalk API.
func GetUserAccessTokenURL() string {
if IsClientIDFromMCP() {
return GetMCPBaseURL() + MCPOAuthTokenPath
}
return UserAccessTokenURL
}
// GetRefreshTokenURL returns the appropriate token refresh URL.
// Uses MCP endpoint when clientID is from MCP, otherwise uses direct DingTalk API.
func GetRefreshTokenURL() string {
if IsClientIDFromMCP() {
return GetMCPBaseURL() + MCPRefreshTokenPath
}
return UserAccessTokenURL // DingTalk uses same endpoint for refresh
}
// GetRevokeTokenURL returns the token revocation URL (MCP only).
// Returns empty string if not using MCP mode.
func GetRevokeTokenURL() string {
if IsClientIDFromMCP() {
return GetMCPBaseURL() + MCPRevokeTokenPath
}
return "" // Direct mode doesn't have revoke endpoint
}
// resolveCredentialSource determines the source of the current credentials.
// Returns one of: "flag", "env", "app", "default".
// This is used to track where credentials came from for token refresh.
func resolveCredentialSource() string {
clientMu.RLock()
hasRuntimeOverride := runtimeClientID != "" || runtimeClientSecret != ""
clientMu.RUnlock()
if hasRuntimeOverride {
return "flag"
}
// Check if loaded from app config
if id, _ := ResolveAppCredentials(getDefaultConfigDir()); id != "" {
return "app"
}
if os.Getenv("DWS_CLIENT_ID") != "" || os.Getenv("DWS_CLIENT_SECRET") != "" {
return "env"
}
return "default"
}
// SetClientID allows runtime override of the client ID (e.g., from CLI flags).
func SetClientID(id string) {
clientMu.Lock()
defer clientMu.Unlock()
runtimeClientID = id
}
// SetClientSecret allows runtime override of the client secret (e.g., from CLI flags).
func SetClientSecret(secret string) {
clientMu.Lock()
defer clientMu.Unlock()
runtimeClientSecret = secret
}
// ClientID returns the OAuth client ID with priority:
// 1. Runtime override (CLI flag --client-id)
// 2. Persisted app config (from previous login)
// 3. Environment variable (DWS_CLIENT_ID)
// 4. Default hardcoded value (if not a placeholder)
// Returns empty string if no valid client ID is available.
// Note: MCP server fetch (priority 4 in the full flow) is handled in OAuthProvider.Login()
func ClientID() string {
clientMu.RLock()
override := runtimeClientID
clientMu.RUnlock()
if override != "" {
return override
}
if id := edition.Get().AuthClientID; id != "" {
return id
}
// Try loading from persisted app config
if id, _ := ResolveAppCredentials(getDefaultConfigDir()); id != "" {
return id
}
if v := os.Getenv("DWS_CLIENT_ID"); v != "" {
return v
}
// Only return default if it's not a placeholder
if !strings.HasPrefix(defaultAuthClientID, "<") {
return defaultAuthClientID
}
return ""
}
// ClientSecret returns the OAuth client secret with priority:
// 1. Runtime override (CLI flag --client-secret)
// 2. Persisted app config (from previous login, stored in keychain)
// 3. Environment variable (DWS_CLIENT_SECRET)
// 4. Default hardcoded value
func ClientSecret() string {
clientMu.RLock()
override := runtimeClientSecret
clientMu.RUnlock()
if override != "" {
return override
}
// Try loading from persisted app config (secret is in keychain)
if _, secret := ResolveAppCredentials(getDefaultConfigDir()); secret != "" {
return secret
}
if v := os.Getenv("DWS_CLIENT_SECRET"); v != "" {
return v
}
return defaultAuthSecret
}
// HasValidClientSecret returns true if a valid client secret is available.
// A valid secret is one that is not a placeholder (e.g., <YOUR_CLIENT_SECRET>).
func HasValidClientSecret() bool {
secret := ClientSecret()
return secret != "" && !strings.HasPrefix(secret, "<")
}
// getRuntimeCredentials returns the runtime-override credentials if set.
// Returns empty strings if no runtime overrides were provided.
func getRuntimeCredentials() (clientID, clientSecret string) {
clientMu.RLock()
defer clientMu.RUnlock()
return runtimeClientID, runtimeClientSecret
}
func getCompleteRuntimeCredentials() (clientID, clientSecret string, ok bool) {
clientID, clientSecret = getRuntimeCredentials()
return clientID, clientSecret, strings.TrimSpace(clientID) != "" && strings.TrimSpace(clientSecret) != ""
}
// getDefaultConfigDir returns the default configuration directory.
// Priority: DWS_CONFIG_DIR env var > ~/.dws
func getDefaultConfigDir() string {
if envDir := os.Getenv("DWS_CONFIG_DIR"); envDir != "" {
return envDir
}
homeDir, err := authUserHomeDir()
if err != nil {
return ".dws"
}
return filepath.Join(homeDir, ".dws")
}