Compare commits

...

2 Commits

Author SHA1 Message Date
kiraWangRuilong
844c6eb30f refactor: enhance file lock and token file directory writable checking logic 2026-07-31 18:02:38 +08:00
kiraWangRuilong
de28420edb feat: optimize refresh token error handling 2026-07-31 18:02:38 +08:00
4 changed files with 463 additions and 130 deletions

View File

@@ -40,15 +40,23 @@ func MaskToken(token string) string {
// GetStoredToken reads the stored UAT for a given (appId, userOpenId) pair.
func GetStoredToken(appId, userOpenId string) *StoredUAToken {
token, _ := readStoredToken(appId, userOpenId)
return token
}
func readStoredToken(appId, userOpenId string) (*StoredUAToken, error) {
jsonStr, err := keychain.Get(keychain.LarkCliService, accountKey(appId, userOpenId))
if err != nil || jsonStr == "" {
return nil
if err != nil {
return nil, err
}
if jsonStr == "" {
return nil, nil
}
var token StoredUAToken
if err := json.Unmarshal([]byte(jsonStr), &token); err != nil {
return nil
return nil, err
}
return &token
return &token, nil
}
// SetStoredToken persists a UAT.
@@ -66,6 +74,54 @@ func RemoveStoredToken(appId, userOpenId string) error {
return keychain.Remove(keychain.LarkCliService, accountKey(appId, userOpenId))
}
// sameStoredTokenGeneration reports whether two snapshots represent the same
// refresh-token generation. Access tokens are used only for case that does not
// contain a refresh token.
func isSameStoredTokenGeneration(current, expected *StoredUAToken) bool {
if current == nil || expected == nil ||
current.AppId != expected.AppId ||
current.UserOpenId != expected.UserOpenId {
return false
}
if current.RefreshToken != "" || expected.RefreshToken != "" {
return current.RefreshToken == expected.RefreshToken
}
return current.AccessToken == expected.AccessToken
}
// setStoredTokenIfCurrent stores updated only when expected is still the
// current token generation. It returns the token present after the check and
// whether the update was applied.
func setStoredTokenIfCurrent(expected, updated *StoredUAToken) (*StoredUAToken, bool, error) {
current, err := readStoredToken(expected.AppId, expected.UserOpenId)
if err != nil {
return nil, false, err
}
if !isSameStoredTokenGeneration(current, expected) {
return current, false, nil
}
if err := SetStoredToken(updated); err != nil {
return current, false, err
}
return updated, true, nil
}
// removeStoredTokenIfCurrent removes expected only when it is still the
// current token generation. It returns the token retained on a mismatch.
func removeStoredTokenIfCurrent(expected *StoredUAToken) (*StoredUAToken, bool, error) {
current, err := readStoredToken(expected.AppId, expected.UserOpenId)
if err != nil {
return nil, false, err
}
if !isSameStoredTokenGeneration(current, expected) {
return current, false, nil
}
if err := RemoveStoredToken(expected.AppId, expected.UserOpenId); err != nil {
return current, false, err
}
return nil, true, nil
}
// TokenStatus determines the freshness of a stored token.
func TokenStatus(token *StoredUAToken) string {
now := time.Now().UnixMilli()

View File

@@ -4,17 +4,18 @@
package auth
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"net/http/httptrace"
"os"
"path/filepath"
"regexp"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/gofrs/flock"
@@ -81,7 +82,7 @@ func GetValidAccessToken(httpClient *http.Client, opts UATCallOptions) (string,
}
if status == "needs_refresh" {
refreshed, err := refreshWithLock(httpClient, opts, stored)
refreshed, err := refreshWithLock(httpClient, opts)
if err != nil {
return "", err
}
@@ -103,7 +104,7 @@ func GetValidAccessToken(httpClient *http.Client, opts UATCallOptions) (string,
}
// refreshWithLock acquires a file lock before attempting to refresh the token.
func refreshWithLock(httpClient *http.Client, opts UATCallOptions, stored *StoredUAToken) (*StoredUAToken, error) {
func refreshWithLock(httpClient *http.Client, opts UATCallOptions) (*StoredUAToken, error) {
key := fmt.Sprintf("%s:%s", opts.AppId, opts.UserOpenId)
// 1. Process-level lock (prevents multiple goroutines in the same process)
@@ -125,12 +126,9 @@ func refreshWithLock(httpClient *http.Client, opts UATCallOptions, stored *Store
refreshLocks.Delete(key)
}()
// 2. Cross-process lock using flock
// We use the same underlying storage directory resolution as keychain_other.go
// to ensure locks are isolated properly alongside other sensitive data.
configDir := core.GetConfigDir()
lockDir := filepath.Join(configDir, "locks")
// 2. Cross-process lock using the global config directory so all
// workspaces sharing the same token also share the same lock.
lockDir := filepath.Join(core.GetBaseConfigDir(), "locks")
if err := vfs.MkdirAll(lockDir, 0700); err != nil {
return nil, fmt.Errorf("failed to create lock directory: %w", err)
}
@@ -153,21 +151,91 @@ func refreshWithLock(httpClient *http.Client, opts UATCallOptions, stored *Store
}
defer fileLock.Unlock()
// 3. Double-checked locking: Check if another process has already refreshed the token
freshStored := GetStoredToken(opts.AppId, opts.UserOpenId)
if freshStored != nil {
status := TokenStatus(freshStored)
if status == "valid" {
// Another process refreshed it, we can just use the new token
if opts.ErrOut != nil {
fmt.Fprintf(opts.ErrOut, "[lark-cli] uat-client: token already refreshed by another process\n")
}
return freshStored, nil
// 3. Re-read under the global lock and use only the current generation.
freshStored, err := readStoredToken(opts.AppId, opts.UserOpenId)
if err != nil {
return nil, err
}
if freshStored == nil {
return nil, nil
}
switch TokenStatus(freshStored) {
case "valid":
if opts.ErrOut != nil {
fmt.Fprintf(opts.ErrOut, "[lark-cli] uat-client: token already refreshed by another process\n")
}
return freshStored, nil
case "expired":
retained, removed, err := removeStoredTokenIfCurrent(freshStored)
if err != nil {
return nil, err
}
if !removed {
return storedTokenAfterGenerationChange(retained, opts.UserOpenId)
}
if opts.ErrOut != nil {
fmt.Fprintf(opts.ErrOut, "[lark-cli] uat-client: refresh_token expired for %s, clearing\n", opts.UserOpenId)
}
return nil, nil
}
if err := ensureDirWritable(lockDir, "tmp_writetest-*"); err != nil {
if opts.ErrOut != nil {
fmt.Fprintf(opts.ErrOut,
"[lark-cli] [WARN] uat-client: refresh lock directory is not writable while refreshing: %v\n",
err)
}
return nil, err
}
// 4. Actually perform the refresh
return doRefreshToken(httpClient, opts, stored)
return doRefreshToken(httpClient, opts, freshStored)
}
const refreshMaxAttempts = 2
type refreshRequest struct {
GrantType string `json:"grant_type"`
RefreshToken string `json:"refresh_token"`
ClientID string `json:"client_id"`
ClientSecret string `json:"client_secret"`
}
// refreshResponse contains only fields documented by the OAuth token endpoint.
// Pointers distinguish an omitted numeric field from a real zero value.
type refreshResponse struct {
Code *int `json:"code"`
AccessToken string `json:"access_token"`
ExpiresIn *int64 `json:"expires_in"`
RefreshToken string `json:"refresh_token"`
RefreshTokenExpiresIn *int64 `json:"refresh_token_expires_in"`
TokenType string `json:"token_type"`
Scope string `json:"scope"`
Error string `json:"error"`
ErrorDescription string `json:"error_description"`
}
// refreshAction describes both retry behavior and local token disposition.
type refreshAction uint8
const (
// refreshSaveResponse saves a successful response.
refreshSaveResponse refreshAction = iota
// refreshRetryAndPreserve retries, preserving the stored token if retry fails.
refreshRetryAndPreserve
// refreshRetryAndClear retries, clearing the stored token if retry fails.
refreshRetryAndClear
// refreshStopAndPreserve stops without clearing the stored token.
refreshStopAndPreserve
// refreshStopAndClear stops and clears the stored token.
refreshStopAndClear
)
type refreshResult struct {
action refreshAction
response refreshResponse
err error
}
// doRefreshToken performs the actual HTTP request to refresh the token.
@@ -177,141 +245,318 @@ func doRefreshToken(httpClient *http.Client, opts UATCallOptions, stored *Stored
errOut = os.Stderr
}
now := time.Now().UnixMilli()
if now >= stored.RefreshExpiresAt {
if time.Now().UnixMilli() >= stored.RefreshExpiresAt {
fmt.Fprintf(errOut, "[lark-cli] uat-client: refresh_token expired for %s, clearing\n", opts.UserOpenId)
if err := RemoveStoredToken(opts.AppId, opts.UserOpenId); err != nil {
retained, removed, err := removeStoredTokenIfCurrent(stored)
if err != nil {
fmt.Fprintf(errOut, "[lark-cli] [WARN] uat-client: failed to remove expired token: %v\n", err)
return nil, err
}
if !removed {
return storedTokenAfterGenerationChange(retained, opts.UserOpenId)
}
return nil, nil
}
endpoints := ResolveOAuthEndpoints(opts.Domain)
endpoint := ResolveOAuthEndpoints(opts.Domain).Token
uncertain := false
for attempt := 1; attempt <= refreshMaxAttempts; attempt++ {
result := refreshOnce(httpClient, endpoint, opts, stored)
if result.action == refreshSaveResponse {
return saveRefreshResponse(opts, stored, result.response)
}
callEndpoint := func() (map[string]interface{}, error) {
form := url.Values{}
form.Set("grant_type", "refresh_token")
form.Set("refresh_token", stored.RefreshToken)
form.Set("client_id", opts.AppId)
form.Set("client_secret", opts.AppSecret)
switch result.action {
case refreshRetryAndPreserve, refreshRetryAndClear:
if result.action == refreshRetryAndClear {
uncertain = true
}
if attempt < refreshMaxAttempts {
fmt.Fprintf(errOut,
"[lark-cli] [WARN] uat-client: refresh attempt %d/%d failed for %s: %v; retrying\n",
attempt, refreshMaxAttempts, opts.UserOpenId, result.err)
continue
}
case refreshStopAndPreserve, refreshStopAndClear:
default:
return nil, errs.NewInternalError(errs.SubtypeUnknown,
"unrecognized token refresh action %d", result.action)
}
req, err := http.NewRequest("POST", endpoints.Token, strings.NewReader(form.Encode()))
clearToken := result.action == refreshStopAndClear ||
result.action == refreshRetryAndClear ||
(result.action == refreshRetryAndPreserve && uncertain)
if !clearToken {
fmt.Fprintf(errOut,
"[lark-cli] [WARN] uat-client: refresh failed for %s, preserving token: %v\n",
opts.UserOpenId, result.err)
return nil, result.err
}
if problem, ok := errs.ProblemOf(result.err); ok {
problem.Retryable = false
}
retained, removed, err := removeStoredTokenIfCurrent(stored)
if err != nil {
fmt.Fprintf(errOut, "[lark-cli] [WARN] uat-client: failed to remove token: %v\n", err)
return nil, err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := httpClient.Do(req)
if err != nil {
return nil, err
if !removed {
fmt.Fprintf(errOut,
"[lark-cli] [WARN] uat-client: stored token changed during refresh for %s, preserving current token\n",
opts.UserOpenId)
return storedTokenAfterGenerationChange(retained, opts.UserOpenId)
}
defer resp.Body.Close()
logHTTPResponse(resp)
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("token refresh read error: %v", err)
}
var data map[string]interface{}
if err := json.Unmarshal(body, &data); err != nil {
return nil, fmt.Errorf("token refresh parse error: %w", err)
}
return data, nil
fmt.Fprintf(errOut,
"[lark-cli] [WARN] uat-client: refresh failed for %s, token cleared: %v\n",
opts.UserOpenId, result.err)
return nil, result.err
}
data, err := callEndpoint()
return nil, errs.NewInternalError(errs.SubtypeUnknown,
"token refresh exhausted attempts without a result")
}
func refreshOnce(httpClient *http.Client, endpoint string, opts UATCallOptions, stored *StoredUAToken) refreshResult {
payload, err := json.Marshal(refreshRequest{
GrantType: "refresh_token",
RefreshToken: stored.RefreshToken,
ClientID: opts.AppId,
ClientSecret: opts.AppSecret,
})
if err != nil {
return nil, err
}
code := getInt(data, "code", -1)
meta, metaOK := errclass.LookupCodeMeta(code)
if metaOK && meta.Category == errs.CategoryPolicy {
challengeUrl := getStr(data, "challenge_url")
cliHint := getStr(data, "cli_hint")
msg := getStr(data, "error_description")
return nil, &errs.SecurityPolicyError{
Problem: errs.Problem{
Category: errs.CategoryPolicy,
Subtype: meta.Subtype,
Code: code,
Message: msg,
Hint: cliHint,
},
ChallengeURL: challengeUrl,
return refreshResult{
action: refreshStopAndPreserve,
err: errs.NewInternalError(errs.SubtypeSDKError,
"failed to encode token refresh request: %v", err).
WithCause(err),
}
}
errStr := getStr(data, "error")
var wroteRequest atomic.Bool
trace := &httptrace.ClientTrace{
WroteRequest: func(httptrace.WroteRequestInfo) {
wroteRequest.Store(true)
},
}
ctx := httptrace.WithClientTrace(context.Background(), trace)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(payload))
if err != nil {
return refreshResult{
action: refreshStopAndPreserve,
err: errs.NewInternalError(errs.SubtypeSDKError,
"failed to create token refresh request: %v", err).
WithCause(err),
}
}
req.Header.Set("Content-Type", "application/json; charset=utf-8")
if (code != -1 && code != 0) || errStr != "" {
// Retryable server error: retry once, then clear token on second failure.
if metaOK && meta.Category == errs.CategoryAuthentication && meta.Retryable {
fmt.Fprintf(errOut, "[lark-cli] [WARN] uat-client: refresh transient error (code=%d) for %s, retrying once\n", code, opts.UserOpenId)
data, err = callEndpoint()
if err != nil {
fmt.Fprintf(errOut, "[lark-cli] [WARN] uat-client: refresh retry network error for %s, clearing token\n", opts.UserOpenId)
if err := RemoveStoredToken(opts.AppId, opts.UserOpenId); err != nil {
fmt.Fprintf(errOut, "[lark-cli] [WARN] uat-client: failed to remove token: %v\n", err)
}
return nil, nil
resp, err := httpClient.Do(req)
if err != nil {
action := refreshRetryAndPreserve
if wroteRequest.Load() {
action = refreshRetryAndClear
}
return refreshResult{action: action, err: err}
}
defer resp.Body.Close()
logHTTPResponse(resp)
body, err := io.ReadAll(resp.Body)
if err != nil {
return refreshResult{
action: refreshRetryAndClear,
err: errs.NewNetworkError(errs.SubtypeNetworkTransport,
"token refresh response read failed: %v", err).
WithRetryable().
WithCause(err),
}
}
var parsed refreshResponse
if err := json.Unmarshal(body, &parsed); err != nil {
return refreshResult{
action: refreshRetryAndClear,
err: errs.NewInternalError(errs.SubtypeInvalidResponse,
"token refresh returned invalid JSON: %v", err).
WithRetryable().
WithCause(err),
}
}
if parsed.Code == nil {
return refreshResult{
action: refreshRetryAndClear,
err: errs.NewInternalError(errs.SubtypeInvalidResponse,
"token refresh response is missing required field code").
WithRetryable(),
}
}
code := *parsed.Code
if code != 0 {
if meta, ok := errclass.LookupCodeMeta(code); ok && meta.Category == errs.CategoryPolicy {
var policyFields struct {
ChallengeURL string `json:"challenge_url"`
CLIHint string `json:"cli_hint"`
}
code = getInt(data, "code", -1)
errStr = getStr(data, "error")
if (code != -1 && code != 0) || errStr != "" {
fmt.Fprintf(errOut, "[lark-cli] [WARN] uat-client: refresh failed after retry (code=%d) for %s, clearing token\n", code, opts.UserOpenId)
if err := RemoveStoredToken(opts.AppId, opts.UserOpenId); err != nil {
fmt.Fprintf(errOut, "[lark-cli] [WARN] uat-client: failed to remove token: %v\n", err)
}
return nil, nil
_ = json.Unmarshal(body, &policyFields)
return refreshResult{
action: refreshStopAndPreserve,
err: &errs.SecurityPolicyError{
Problem: errs.Problem{
Category: errs.CategoryPolicy,
Subtype: meta.Subtype,
Code: code,
Message: parsed.ErrorDescription,
Hint: policyFields.CLIHint,
},
ChallengeURL: policyFields.ChallengeURL,
},
}
// Retry succeeded, fall through to parse token below.
}
message := parsed.ErrorDescription
if message == "" {
message = parsed.Error
}
// BuildAPIError accepts the common OpenAPI message key; OAuth names
// the same value error_description.
apiErr := errclass.BuildAPIError(map[string]any{
"code": code,
"msg": message,
}, errclass.ClassifyContext{
Brand: string(opts.Domain),
AppID: opts.AppId,
Identity: "user",
})
if authErr, ok := apiErr.(*errs.AuthenticationError); ok {
authErr.UserOpenID = opts.UserOpenId
}
return refreshResult{action: refreshActionForCode(code), err: apiErr}
}
if parsed.RefreshToken == "" {
parsed.RefreshToken = stored.RefreshToken
}
if parsed.AccessToken == "" {
return refreshResult{
action: refreshStopAndPreserve,
err: errs.NewInternalError(errs.SubtypeInvalidResponse,
"token refresh response is missing required field access_token").
WithRetryable(),
}
}
if parsed.ExpiresIn == nil || *parsed.ExpiresIn <= 0 {
parsed.ExpiresIn = new(int64)
*parsed.ExpiresIn = 7200 // 2 hours
}
if parsed.RefreshTokenExpiresIn == nil || *parsed.RefreshTokenExpiresIn <= 0 {
parsed.RefreshTokenExpiresIn = new(int64)
if stored.RefreshExpiresAt <= 0 {
*parsed.RefreshTokenExpiresIn = 2592000 // 30 days
} else {
// All other errors: clear token, require re-authorization.
fmt.Fprintf(errOut, "[lark-cli] [WARN] uat-client: refresh failed (code=%d), clearing token for %s\n", code, opts.UserOpenId)
if err := RemoveStoredToken(opts.AppId, opts.UserOpenId); err != nil {
fmt.Fprintf(errOut, "[lark-cli] [WARN] uat-client: failed to remove token: %v\n", err)
}
return nil, nil
now := time.Now().UnixMilli()
*parsed.RefreshTokenExpiresIn = (stored.RefreshExpiresAt - now) / 1000
}
}
accessToken := getStr(data, "access_token")
if accessToken == "" {
return nil, fmt.Errorf("Token refresh returned no access_token")
}
return refreshResult{action: refreshSaveResponse, response: parsed}
}
refreshToken := getStr(data, "refresh_token")
if refreshToken == "" {
refreshToken = stored.RefreshToken
func refreshActionForCode(code int) refreshAction {
meta, ok := errclass.LookupCodeMeta(code)
switch {
case !ok:
return refreshRetryAndClear
case meta.Category == errs.CategoryPolicy:
return refreshStopAndPreserve
case meta.Retryable:
return refreshRetryAndPreserve
default:
return refreshStopAndClear
}
}
expiresIn := getInt(data, "expires_in", 7200)
refreshExpiresIn := getInt(data, "refresh_token_expires_in", 0)
refreshExpiresAt := stored.RefreshExpiresAt
if refreshExpiresIn > 0 {
refreshExpiresAt = now + int64(refreshExpiresIn)*1000
}
scope := getStr(data, "scope")
if scope == "" {
scope = stored.Scope
}
func saveRefreshResponse(opts UATCallOptions, stored *StoredUAToken, response refreshResponse) (*StoredUAToken, error) {
now := time.Now().UnixMilli()
updated := &StoredUAToken{
UserOpenId: stored.UserOpenId,
AppId: opts.AppId,
AccessToken: accessToken,
RefreshToken: refreshToken,
ExpiresAt: now + int64(expiresIn)*1000,
RefreshExpiresAt: refreshExpiresAt,
Scope: scope,
AccessToken: response.AccessToken,
RefreshToken: response.RefreshToken,
ExpiresAt: now + *response.ExpiresIn*1000,
RefreshExpiresAt: now + *response.RefreshTokenExpiresIn*1000,
Scope: response.Scope,
GrantedAt: stored.GrantedAt,
}
if err := SetStoredToken(updated); err != nil {
current, saved, err := setStoredTokenIfCurrent(stored, updated)
if err != nil {
return nil, err
}
if !saved {
if opts.ErrOut != nil {
fmt.Fprintf(opts.ErrOut,
"[lark-cli] [WARN] uat-client: stored token changed during refresh for %s, preserving current token\n",
opts.UserOpenId)
}
return storedTokenAfterGenerationChange(current, opts.UserOpenId)
}
return updated, nil
}
func storedTokenAfterGenerationChange(current *StoredUAToken, userOpenId string) (*StoredUAToken, error) {
if current == nil {
return nil, nil
}
if TokenStatus(current) == "valid" {
return current, nil
}
return nil, errs.NewInternalError(errs.SubtypeStorage,
"stored refresh token changed while refreshing user %q", userOpenId).
WithRetryable().
WithHint("retry the command")
}
func ensureDirWritable(dir, tempPrefix string) error {
if dir == "" {
return nil
}
if err := vfs.MkdirAll(dir, 0700); err != nil {
return errs.NewInternalError(errs.SubtypeFileIO,
"failed to access refresh lock directory %q", dir).
WithCause(err).
WithHint("If running in a sandbox or read-only workspace, grant write access for this directory and retry.")
}
tmp, err := vfs.CreateTemp(dir, tempPrefix)
if err != nil {
return errs.NewInternalError(errs.SubtypeFileIO,
"failed to create temporary file in refresh lock directory %q", dir).
WithCause(err).
WithHint("If running in a sandbox or read-only workspace, grant write access for this directory and retry.")
}
tmpName := tmp.Name()
closeErr := tmp.Close()
if removeErr := vfs.Remove(tmpName); removeErr != nil {
err := fmt.Errorf("%v", removeErr)
if closeErr != nil {
err = fmt.Errorf("%v; also failed to close temp file: %v", removeErr, closeErr)
}
return errs.NewInternalError(errs.SubtypeFileIO,
"failed to clean up refresh lock write-check file %q", tmpName).
WithCause(err)
}
if closeErr != nil {
return errs.NewInternalError(errs.SubtypeFileIO,
"failed to close refresh lock write-check file %q", tmpName).
WithCause(closeErr)
}
return nil
}

View File

@@ -38,11 +38,13 @@ var codeMeta = map[int]CodeMeta{
99991668: {Category: errs.CategoryAuthentication, Subtype: errs.SubtypeTokenInvalid}, // UAT invalid/expired (server does not distinguish)
99991663: {Category: errs.CategoryAuthentication, Subtype: errs.SubtypeTokenInvalid}, // access_token invalid
99991677: {Category: errs.CategoryAuthentication, Subtype: errs.SubtypeTokenExpired}, // UAT expired
20026: {Category: errs.CategoryAuthentication, Subtype: errs.SubtypeRefreshTokenInvalid}, // refresh_token v1 legacy format
20024: {Category: errs.CategoryAuthentication, Subtype: errs.SubtypeRefreshTokenInvalid}, // authorization code or refresh_token does not match client_id
20026: {Category: errs.CategoryAuthentication, Subtype: errs.SubtypeRefreshTokenInvalid}, // refresh_token is invalid or v1 legacy format
20037: {Category: errs.CategoryAuthentication, Subtype: errs.SubtypeRefreshTokenExpired}, // refresh_token expired
20064: {Category: errs.CategoryAuthentication, Subtype: errs.SubtypeRefreshTokenRevoked}, // refresh_token revoked
20073: {Category: errs.CategoryAuthentication, Subtype: errs.SubtypeRefreshTokenReused}, // refresh_token already used
20050: {Category: errs.CategoryAuthentication, Subtype: errs.SubtypeRefreshServerError, Retryable: true}, // refresh endpoint transient error
20064: {Category: errs.CategoryAuthentication, Subtype: errs.SubtypeRefreshTokenRevoked}, // refresh_token revoked
20072: {Category: errs.CategoryAuthentication, Subtype: errs.SubtypeRefreshServerError}, // refresh endpoint temporarily unavailable
20073: {Category: errs.CategoryAuthentication, Subtype: errs.SubtypeRefreshTokenReused}, // refresh_token already used
// CategoryAuthorization
99991672: {Category: errs.CategoryAuthorization, Subtype: errs.SubtypeAppScopeNotApplied},
@@ -51,6 +53,13 @@ var codeMeta = map[int]CodeMeta{
230027: {Category: errs.CategoryAuthorization, Subtype: errs.SubtypeUserUnauthorized}, // user never authorized the app
99991673: {Category: errs.CategoryAuthorization, Subtype: errs.SubtypeAppUnavailable}, // app status unavailable
99991662: {Category: errs.CategoryAuthorization, Subtype: errs.SubtypeAppDisabled}, // app currently disabled in tenant
20008: {Category: errs.CategoryAuthorization, Subtype: errs.SubtypeUserUnauthorized}, // user does not exist
20009: {Category: errs.CategoryAuthorization, Subtype: errs.SubtypeAppUnavailable}, // app specified is not installed
20010: {Category: errs.CategoryAuthorization, Subtype: errs.SubtypeUserUnauthorized}, // user does not have permission to use this app
20048: {Category: errs.CategoryAuthorization, Subtype: errs.SubtypeAppUnavailable}, // app specified is not exist
20066: {Category: errs.CategoryAuthorization, Subtype: errs.SubtypeUserUnauthorized}, // user staus is not normal
20069: {Category: errs.CategoryAuthorization, Subtype: errs.SubtypeAppDisabled}, // app specified is disabled
20074: {Category: errs.CategoryAuthorization, Subtype: errs.SubtypeAppUnavailable}, // app specified not allows for refresh token
// CategoryAPI
99991400: {Category: errs.CategoryAPI, Subtype: errs.SubtypeRateLimit, Retryable: true},
@@ -62,10 +71,17 @@ var codeMeta = map[int]CodeMeta{
1063006: {Category: errs.CategoryAPI, Subtype: errs.SubtypeRateLimit}, // drive perm-apply quota; 5/day, not short-term retryable
1063007: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters},
231205: {Category: errs.CategoryAPI, Subtype: errs.SubtypeOwnershipMismatch},
20001: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // request missing required parameter
20036: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // grant_type not supported
20063: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // request format error
20067: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // scope list contains duplicated items
20068: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // scope list contains forbidden permissions
20070: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // request provide multiple authorization methods
// CategoryConfig
99991543: {Category: errs.CategoryConfig, Subtype: errs.SubtypeInvalidClient}, // RFC 6749 §5.2 — app_id / app_secret incorrect (Open API)
10014: {Category: errs.CategoryConfig, Subtype: errs.SubtypeInvalidClient}, // legacy TAT endpoint — "app secret invalid" (pre-v3 variant of 99991543; CLI now reports invalid_client)
20002: {Category: errs.CategoryConfig, Subtype: errs.SubtypeInvalidClient}, // client secret invalid
// CategoryPolicy
21000: {Category: errs.CategoryPolicy, Subtype: errs.SubtypeChallengeRequired},

View File

@@ -23,11 +23,27 @@ func TestLookupCodeMeta_CredentialCodes(t *testing.T) {
{99991668, errs.CategoryAuthentication, errs.SubtypeTokenInvalid, false},
{99991663, errs.CategoryAuthentication, errs.SubtypeTokenInvalid, false},
{99991677, errs.CategoryAuthentication, errs.SubtypeTokenExpired, false},
{20024, errs.CategoryAuthentication, errs.SubtypeRefreshTokenInvalid, false},
{20026, errs.CategoryAuthentication, errs.SubtypeRefreshTokenInvalid, false},
{20037, errs.CategoryAuthentication, errs.SubtypeRefreshTokenExpired, false},
{20064, errs.CategoryAuthentication, errs.SubtypeRefreshTokenRevoked, false},
{20073, errs.CategoryAuthentication, errs.SubtypeRefreshTokenReused, false},
{20050, errs.CategoryAuthentication, errs.SubtypeRefreshServerError, true},
{20064, errs.CategoryAuthentication, errs.SubtypeRefreshTokenRevoked, false},
{20072, errs.CategoryAuthentication, errs.SubtypeRefreshServerError, false},
{20073, errs.CategoryAuthentication, errs.SubtypeRefreshTokenReused, false},
{20008, errs.CategoryAuthorization, errs.SubtypeUserUnauthorized, false},
{20009, errs.CategoryAuthorization, errs.SubtypeAppUnavailable, false},
{20010, errs.CategoryAuthorization, errs.SubtypeUserUnauthorized, false},
{20048, errs.CategoryAuthorization, errs.SubtypeAppUnavailable, false},
{20066, errs.CategoryAuthorization, errs.SubtypeUserUnauthorized, false},
{20069, errs.CategoryAuthorization, errs.SubtypeAppDisabled, false},
{20074, errs.CategoryAuthorization, errs.SubtypeAppUnavailable, false},
{20001, errs.CategoryAPI, errs.SubtypeInvalidParameters, false},
{20036, errs.CategoryAPI, errs.SubtypeInvalidParameters, false},
{20063, errs.CategoryAPI, errs.SubtypeInvalidParameters, false},
{20067, errs.CategoryAPI, errs.SubtypeInvalidParameters, false},
{20068, errs.CategoryAPI, errs.SubtypeInvalidParameters, false},
{20070, errs.CategoryAPI, errs.SubtypeInvalidParameters, false},
{20002, errs.CategoryConfig, errs.SubtypeInvalidClient, false},
}
for _, tc := range cases {
t.Run(fmt.Sprintf("%d", tc.code), func(t *testing.T) {