* feat(auth): support multi-profile login
* fix(auth): complete multi-org profile acceptance
* feat(auth): 完成多组织 profile 验收
* docs(auth): 补充多组织 Ralph 验收材料
* feat(auth): 支持 auth switch TUI 切换 profile
* feat(auth): logout 默认清理所有组织
* feat(auth): login 默认新增组织授权
* feat(profile): 使用 profile switch 切换组织
* docs(ralph): 更新 profile switch 验收材料
* fix(profile): 展示全部可切换组织
* feat(profile): support multi-org switch tui
* chore(install): add branch source installer
* fix(profile): keep global profile out of tool params
* feat(profile): support csv multi-profile runtime
* ci: add multi-profile e2e workflow
* ci: run multi-profile e2e on all branches
* docs: document multi-profile e2e ci gate
* docs: remove multi-profile test cases from pr
* ci: harden multi-profile e2e gates
* fix(auth): serialize profiles.json RMW and harden multi-profile persistence
Wrap all profiles.json read-modify-write paths (profile switch/use/remove,
status marking, token save, logout) in the existing dual-layer lock via a new
withProfilesLock helper. Split each writer into a public (locking) entry point
plus a lock-free *Locked variant so the non-reentrant lock is never re-acquired;
the refresh path (oauth_helpers) and the load-path legacy migration now call the
lock-free saver to avoid self-deadlock.
Also: write profiles.json and the token marker via per-write random temp names
(uuid) to stop concurrent writers from corrupting a fixed .tmp file; quarantine
an unparseable profiles.json and rebuild an empty config so the CLI can
self-heal instead of locking out auth reset/logout; make DeleteAllTokenData
proceed even if profiles.json cannot be read; and stop SyncLegacyTokenMirror
from deleting the legacy mirror on a transient keychain read error.
* fix(auth): do not fall back to a different org's legacy token slot
When no explicit --profile is given, LoadTokenDataForProfile resolves the
current/primary profile and reads its per-corp keychain slot. If that slot
read failed, the code silently fell through to the legacy single token slot,
which after any drift between the legacy mirror and the current profile could
belong to a different organization. The command would then run as the wrong
org with no indication to the user.
Reproduction (conceptual):
- profiles.json currentProfile = corpA
- corpA's keychain slot is unreadable, legacy single slot still holds corpB
- any read command (no --profile) silently used corpB's token
Fix: when a profile is resolved but its slot read fails and no --profile was
given, only fall back to the legacy single slot when its CorpID matches the
resolved profile (same org); otherwise return the original error instead of
acting as a different organization. The no-profile legacy path (pre-migration
installs with no resolved profile) is unchanged.
Tests:
- Covered by the existing internal/auth suite under go test -race; the
same-org fallback preserves the legacy-mirror case while the cross-org
case now surfaces the read error.
* feat(skill): document multi-org profile usage and always ship dws-shared
The skills had no guidance on the multi-profile capability, so an agent would
treat the CLI as single-org: when a lookup missed in the current org it would
give up or ask the user instead of searching other logged-in orgs. The multi
skill set also referenced a `dws-shared` prerequisite that was never actually
installed, and the only multi-org hints lived inline in three product skills.
This adds, in source only:
- A "multi-org / profile" section in the mono SKILL.md (concept, commands,
cross-org rule, aggregation, safety guardrails) plus a decision-tree entry,
trigger conditions, and a corrected logout danger-table row (logout removes
all orgs by default; removing the primary silently re-elects a new primary,
confirm before removing the primary).
- A standalone skills/multi/dingtalk-profile skill mirroring the same content.
- A new skills/multi/dws-shared skill that carries auth, global flags and the
multi-org rule, so every product skill's PREREQUISITE resolves and all
read/search skills inherit the cross-org behavior without per-skill edits.
- Cross-org fallback notes on dingtalk-aisearch / chat / contact.
To guarantee the prerequisite actually ships, multi-mode install now force-
includes dws-shared even when --skill / --exclude narrows the set (no-op when
the source has no dws-shared, preserving older layouts).
Tests:
- internal/app: TestP1SharedAlwaysIncludedWithSkillFilter installs with
`-s aitable` and asserts dws-shared still lands in the destination;
TestP1SharedNoopWhenAbsent guards the older-layout no-op.
- go test -race ./internal/auth/... ./internal/app/... passes.
---------
Co-authored-by: shangguanxuan.sgx <shangguanxuan.sgx@alibaba-inc.com>
Co-authored-by: qinze <audanye@gmail.com>
401 lines
13 KiB
Go
401 lines
13 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"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
|
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
|
)
|
|
|
|
// TokenData holds the OAuth token set persisted to disk.
|
|
type TokenData struct {
|
|
AccessToken string `json:"access_token"`
|
|
RefreshToken string `json:"refresh_token"`
|
|
PersistentCode string `json:"persistent_code"`
|
|
ExpiresAt time.Time `json:"expires_at"`
|
|
RefreshExpAt time.Time `json:"refresh_expires_at"`
|
|
CorpID string `json:"corp_id"`
|
|
UserID string `json:"user_id,omitempty"`
|
|
UserName string `json:"user_name,omitempty"`
|
|
CorpName string `json:"corp_name,omitempty"`
|
|
ClientID string `json:"client_id,omitempty"` // Associated app client ID for refresh
|
|
UpdatedAt string `json:"updated_at,omitempty"`
|
|
Source string `json:"source,omitempty"`
|
|
}
|
|
|
|
// IsAccessTokenValid returns true if the access token has not expired.
|
|
func (t *TokenData) IsAccessTokenValid() bool {
|
|
if t == nil || t.AccessToken == "" {
|
|
return false
|
|
}
|
|
// Give 5-minute buffer before actual expiry.
|
|
return time.Now().Before(t.ExpiresAt.Add(-5 * time.Minute))
|
|
}
|
|
|
|
// IsRefreshTokenValid returns true if the refresh token has not expired.
|
|
func (t *TokenData) IsRefreshTokenValid() bool {
|
|
if t == nil || t.RefreshToken == "" {
|
|
return false
|
|
}
|
|
return time.Now().Before(t.RefreshExpAt)
|
|
}
|
|
|
|
// HasPersistentCode returns true if a persistent code is available.
|
|
func (t *TokenData) HasPersistentCode() bool {
|
|
return t != nil && t.PersistentCode != ""
|
|
}
|
|
|
|
const tokenJSONFile = "token.json"
|
|
|
|
// TokenMarker is a lightweight file the host application reads to detect
|
|
// whether the CLI has a valid token without accessing the keychain.
|
|
type TokenMarker struct {
|
|
UpdatedAt string `json:"updated_at"`
|
|
}
|
|
|
|
// WriteTokenMarker writes a token.json marker containing only an updated_at
|
|
// timestamp. The host application uses this file's presence and mtime to
|
|
// decide whether it needs to trigger a new auth exchange.
|
|
func WriteTokenMarker(configDir string) error {
|
|
marker := TokenMarker{UpdatedAt: time.Now().Format(time.RFC3339)}
|
|
data, _ := json.MarshalIndent(marker, "", " ")
|
|
if err := os.MkdirAll(configDir, 0o700); err != nil {
|
|
return err
|
|
}
|
|
tmp := filepath.Join(configDir, tokenJSONFile+"."+uuid.New().String()+".tmp")
|
|
if err := os.WriteFile(tmp, data, 0o600); err != nil {
|
|
return err
|
|
}
|
|
return os.Rename(tmp, filepath.Join(configDir, tokenJSONFile))
|
|
}
|
|
|
|
// DeleteTokenMarker removes the token.json marker file.
|
|
func DeleteTokenMarker(configDir string) error {
|
|
if err := os.Remove(filepath.Join(configDir, tokenJSONFile)); err != nil && !os.IsNotExist(err) {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// SaveTokenData persists TokenData. When an edition hook (SaveToken) is
|
|
// registered, it delegates entirely to the hook; otherwise it falls back
|
|
// to the default keychain-based storage.
|
|
func SaveTokenData(configDir string, data *TokenData) error {
|
|
if h := edition.Get(); h.SaveToken != nil {
|
|
return saveTokenViaHook(h, configDir, data)
|
|
}
|
|
return withProfilesLock(configDir, func() error {
|
|
return saveTokenDataLocked(configDir, data)
|
|
})
|
|
}
|
|
|
|
// saveTokenDataLocked performs the keychain + profiles.json + legacy mirror
|
|
// writes assuming the auth dual-layer lock is already held. Callers that
|
|
// already hold the lock (OAuthProvider refresh path, the legacy secure->keychain
|
|
// migration in LoadTokenDataForProfile) must use this instead of SaveTokenData
|
|
// to avoid deadlocking on the non-reentrant lock.
|
|
func saveTokenDataLocked(configDir string, data *TokenData) error {
|
|
if h := edition.Get(); h.SaveToken != nil {
|
|
return saveTokenViaHook(h, configDir, data)
|
|
}
|
|
if data != nil && strings.TrimSpace(data.CorpID) != "" {
|
|
if err := SaveTokenDataKeychainForCorpID(data.CorpID, data); err != nil {
|
|
return err
|
|
}
|
|
makeCurrent := strings.TrimSpace(RuntimeProfile()) == ""
|
|
if err := upsertProfileFromTokenWithCurrentLocked(configDir, data, makeCurrent); err != nil {
|
|
return err
|
|
}
|
|
if makeCurrent {
|
|
if err := SaveTokenDataKeychain(data); err != nil {
|
|
return err
|
|
}
|
|
} else if err := syncLegacyTokenMirrorLocked(configDir); err != nil {
|
|
return err
|
|
}
|
|
return WriteTokenMarker(configDir)
|
|
}
|
|
if err := SaveTokenDataKeychain(data); err != nil {
|
|
return err
|
|
}
|
|
return WriteTokenMarker(configDir)
|
|
}
|
|
|
|
func saveTokenViaHook(h *edition.Hooks, configDir string, data *TokenData) error {
|
|
jsonData, err := json.MarshalIndent(data, "", " ")
|
|
if err != nil {
|
|
return fmt.Errorf("marshaling token data for hook: %w", err)
|
|
}
|
|
return h.SaveToken(configDir, jsonData)
|
|
}
|
|
|
|
// LoadTokenData reads TokenData. When an edition hook (LoadToken) is
|
|
// registered, it delegates entirely to the hook; otherwise it falls back
|
|
// to keychain with legacy .data migration.
|
|
func LoadTokenData(configDir string) (*TokenData, error) {
|
|
return LoadTokenDataForProfile(configDir, RuntimeProfile())
|
|
}
|
|
|
|
// LoadTokenDataForProfile reads TokenData for a profile selector without mutating
|
|
// currentProfile. Empty selector follows the default resolution chain.
|
|
func LoadTokenDataForProfile(configDir, profile string) (*TokenData, error) {
|
|
if h := edition.Get(); h.LoadToken != nil {
|
|
if strings.TrimSpace(profile) != "" {
|
|
return nil, fmt.Errorf("profile selection is not supported by the current auth backend")
|
|
}
|
|
jsonData, err := h.LoadToken(configDir)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var td TokenData
|
|
if err := json.Unmarshal(jsonData, &td); err != nil {
|
|
return nil, fmt.Errorf("parsing token data from hook: %w", err)
|
|
}
|
|
return &td, nil
|
|
}
|
|
|
|
// Default: keychain with legacy .data migration
|
|
selected, err := resolveProfileForLoad(configDir, profile)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if selected != nil {
|
|
data, err := LoadTokenDataKeychainForCorpID(selected.CorpID)
|
|
if err == nil {
|
|
return data, nil
|
|
}
|
|
if strings.TrimSpace(profile) != "" {
|
|
return nil, err
|
|
}
|
|
// No explicit --profile: `selected` is the resolved current/primary
|
|
// profile. Only fall back to the legacy single slot when it belongs to
|
|
// the SAME org; otherwise surface the error instead of silently acting
|
|
// as a different organization (the legacy mirror may have drifted).
|
|
if legacy, lerr := LoadTokenDataKeychain(); lerr == nil && legacy != nil &&
|
|
strings.TrimSpace(legacy.CorpID) == strings.TrimSpace(selected.CorpID) {
|
|
return legacy, nil
|
|
}
|
|
return nil, err
|
|
}
|
|
if TokenDataExistsKeychain() {
|
|
return LoadTokenDataKeychain()
|
|
}
|
|
data, err := LoadSecureTokenData(configDir)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
// One-time legacy secure-store -> keychain migration. This read path may run
|
|
// while the refresh lock is already held, so use the lock-free saver.
|
|
if err := saveTokenDataLocked(configDir, data); err == nil {
|
|
_ = DeleteSecureData(configDir)
|
|
}
|
|
return data, nil
|
|
}
|
|
|
|
// DeleteTokenData removes token data. When an edition hook (DeleteToken) is
|
|
// registered, it delegates entirely to the hook; otherwise it falls back
|
|
// to keychain + legacy cleanup.
|
|
func DeleteTokenData(configDir string) error {
|
|
return DeleteTokenDataForProfile(configDir, RuntimeProfile())
|
|
}
|
|
|
|
// DeleteTokenDataForProfile removes one profile's token data. Empty selector
|
|
// removes the current/default profile, falling back to legacy single-slot auth.
|
|
func DeleteTokenDataForProfile(configDir, profile string) error {
|
|
if h := edition.Get(); h.DeleteToken != nil {
|
|
if strings.TrimSpace(profile) != "" {
|
|
return fmt.Errorf("profile selection is not supported by the current auth backend")
|
|
}
|
|
return h.DeleteToken(configDir)
|
|
}
|
|
return withProfilesLock(configDir, func() error {
|
|
return deleteTokenDataForProfileLocked(configDir, profile)
|
|
})
|
|
}
|
|
|
|
func deleteTokenDataForProfileLocked(configDir, profile string) error {
|
|
selected, err := resolveProfileForLoad(configDir, profile)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if selected != nil {
|
|
keychainErr := DeleteTokenDataKeychainForCorpID(selected.CorpID)
|
|
_, removeErr := removeProfileLocked(configDir, selected.CorpID)
|
|
legacyErr := syncLegacyTokenMirrorLocked(configDir)
|
|
secureErr := DeleteSecureData(configDir)
|
|
if keychainErr != nil {
|
|
return keychainErr
|
|
}
|
|
if removeErr != nil {
|
|
return removeErr
|
|
}
|
|
if legacyErr != nil {
|
|
return legacyErr
|
|
}
|
|
return secureErr
|
|
}
|
|
|
|
keychainErr := DeleteTokenDataKeychain()
|
|
legacyErr := DeleteSecureData(configDir)
|
|
markerErr := DeleteTokenMarker(configDir)
|
|
if keychainErr != nil {
|
|
return keychainErr
|
|
}
|
|
if legacyErr != nil {
|
|
return legacyErr
|
|
}
|
|
return markerErr
|
|
}
|
|
|
|
// DeleteAllTokenData removes all profile-scoped and legacy token data.
|
|
func DeleteAllTokenData(configDir string) error {
|
|
if h := edition.Get(); h.DeleteToken != nil {
|
|
return h.DeleteToken(configDir)
|
|
}
|
|
return withProfilesLock(configDir, func() error {
|
|
var firstErr error
|
|
// Best-effort: even if profiles.json is unreadable, still clear every
|
|
// other slot so the user can always self-heal via auth reset / logout.
|
|
if cfg, err := LoadProfiles(configDir); err == nil {
|
|
for _, profile := range cfg.Profiles {
|
|
if e := DeleteTokenDataKeychainForCorpID(profile.CorpID); e != nil && firstErr == nil {
|
|
firstErr = e
|
|
}
|
|
}
|
|
}
|
|
if e := os.Remove(ProfilesPath(configDir)); e != nil && !os.IsNotExist(e) && firstErr == nil {
|
|
firstErr = e
|
|
}
|
|
// Sweep any quarantined corrupt-profiles files so they don't accumulate.
|
|
if matches, _ := filepath.Glob(ProfilesPath(configDir) + ".corrupt-*"); len(matches) > 0 {
|
|
for _, m := range matches {
|
|
if e := os.Remove(m); e != nil && !os.IsNotExist(e) && firstErr == nil {
|
|
firstErr = e
|
|
}
|
|
}
|
|
}
|
|
if e := DeleteTokenDataKeychain(); e != nil && firstErr == nil {
|
|
firstErr = e
|
|
}
|
|
if e := DeleteSecureData(configDir); e != nil && firstErr == nil {
|
|
firstErr = e
|
|
}
|
|
if e := DeleteTokenMarker(configDir); e != nil && firstErr == nil {
|
|
firstErr = e
|
|
}
|
|
return firstErr
|
|
})
|
|
}
|
|
|
|
// RevokeTokenRemote calls the appropriate logout/revoke endpoint to invalidate the access token.
|
|
// Uses MCP revoke endpoint when clientID is from MCP, otherwise uses DingTalk logout.
|
|
// This should be called before deleting local token data.
|
|
// The function is best-effort: errors are returned but callers may choose to ignore them.
|
|
func RevokeTokenRemote(ctx context.Context) error {
|
|
// Use MCP revoke endpoint when clientID is from MCP
|
|
if IsClientIDFromMCP() {
|
|
return revokeTokenViaMCP(ctx)
|
|
}
|
|
// Direct mode: use DingTalk logout endpoint
|
|
logoutURL, err := url.Parse(LogoutURL)
|
|
if err != nil {
|
|
return fmt.Errorf("parsing logout URL: %w", err)
|
|
}
|
|
|
|
q := logoutURL.Query()
|
|
q.Set("client_id", ClientID())
|
|
q.Set("continue", LogoutContinueURL)
|
|
logoutURL.RawQuery = q.Encode()
|
|
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, logoutURL.String(), nil)
|
|
if err != nil {
|
|
return fmt.Errorf("creating logout request: %w", err)
|
|
}
|
|
|
|
client := &http.Client{
|
|
Timeout: 10 * time.Second,
|
|
// Do not follow redirects — we just need to hit the logout endpoint.
|
|
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
|
return http.ErrUseLastResponse
|
|
},
|
|
}
|
|
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
return fmt.Errorf("calling logout endpoint: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
// Accept 200 OK or 302 redirect as success.
|
|
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusFound {
|
|
return fmt.Errorf("logout endpoint returned status %d", resp.StatusCode)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// revokeTokenViaMCP revokes token via MCP endpoint.
|
|
func revokeTokenViaMCP(ctx context.Context) error {
|
|
revokeURL := GetRevokeTokenURL()
|
|
if revokeURL == "" {
|
|
return nil // No revoke endpoint available
|
|
}
|
|
|
|
// Load current token to get accessToken
|
|
tokenData, err := LoadTokenData(getDefaultConfigDir())
|
|
if err != nil || tokenData == nil {
|
|
return nil // No token to revoke
|
|
}
|
|
|
|
body := map[string]string{
|
|
"clientId": ClientID(),
|
|
"accessToken": tokenData.AccessToken,
|
|
}
|
|
bodyBytes, err := json.Marshal(body)
|
|
if err != nil {
|
|
return fmt.Errorf("marshaling revoke request: %w", err)
|
|
}
|
|
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, revokeURL, bytes.NewReader(bodyBytes))
|
|
if err != nil {
|
|
return fmt.Errorf("creating revoke request: %w", err)
|
|
}
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
client := &http.Client{Timeout: 10 * time.Second}
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
return fmt.Errorf("calling revoke endpoint: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
return fmt.Errorf("revoke endpoint returned status %d", resp.StatusCode)
|
|
}
|
|
|
|
return nil
|
|
}
|