Files
dingtalk-workspace-cli/internal/auth/token.go
T
2026-03-28 17:51:22 +08:00

145 lines
4.4 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 (
"context"
"fmt"
"net/http"
"net/url"
"time"
)
// 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"`
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 != ""
}
// SaveTokenData saves TokenData to the platform keychain.
// Uses the new keychain-based storage with random master key for better security.
func SaveTokenData(configDir string, data *TokenData) error {
return SaveTokenDataKeychain(data)
}
// LoadTokenData reads TokenData from the platform keychain.
// On first call, it attempts to migrate legacy .data file if present.
func LoadTokenData(configDir string) (*TokenData, error) {
// Try loading from new keychain first
if TokenDataExistsKeychain() {
return LoadTokenDataKeychain()
}
// Fallback: try legacy .data file and migrate
data, err := LoadSecureTokenData(configDir)
if err != nil {
return nil, err
}
// Migrate to keychain for future use
if err := SaveTokenDataKeychain(data); err == nil {
// Successfully migrated, delete legacy file
_ = DeleteSecureData(configDir)
}
return data, nil
}
// DeleteTokenData removes token data from both keychain and legacy storage.
func DeleteTokenData(configDir string) error {
// Delete from keychain
keychainErr := DeleteTokenDataKeychain()
// Also clean up any legacy .data file
legacyErr := DeleteSecureData(configDir)
// Return keychain error if any, otherwise legacy error
if keychainErr != nil {
return keychainErr
}
return legacyErr
}
// RevokeTokenRemote calls the DingTalk logout endpoint to invalidate the access token.
// 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 {
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
}