Files
dingtalk-workspace-cli/internal/auth/token.go
T
2026-03-27 17:55:57 +08:00

117 lines
3.7 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 encrypts and saves TokenData to .data file.
// Uses AES-256-GCM encryption with a key derived from device MAC address.
func SaveTokenData(configDir string, data *TokenData) error {
return SaveSecureTokenData(configDir, data)
}
// LoadTokenData reads TokenData from encrypted .data file.
func LoadTokenData(configDir string) (*TokenData, error) {
return LoadSecureTokenData(configDir)
}
// DeleteTokenData removes encrypted .data file from configDir.
func DeleteTokenData(configDir string) error {
return DeleteSecureData(configDir)
}
// 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
}