866 lines
23 KiB
Go
866 lines
23 KiB
Go
package management
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"runtime"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/router-for-me/CLIProxyAPI/v7/internal/credentialweight"
|
|
sdkAuth "github.com/router-for-me/CLIProxyAPI/v7/sdk/auth"
|
|
coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth"
|
|
)
|
|
|
|
// PatchAuthFileStatus toggles the disabled state of an auth file
|
|
func (h *Handler) PatchAuthFileStatus(c *gin.Context) {
|
|
if h.authManager == nil {
|
|
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "core auth manager unavailable"})
|
|
return
|
|
}
|
|
|
|
var req struct {
|
|
Name string `json:"name"`
|
|
AuthIndex string `json:"auth_index"`
|
|
Disabled *bool `json:"disabled"`
|
|
}
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"})
|
|
return
|
|
}
|
|
|
|
name := strings.TrimSpace(req.Name)
|
|
authIndex := strings.TrimSpace(req.AuthIndex)
|
|
if name == "" {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "name is required"})
|
|
return
|
|
}
|
|
if req.Disabled == nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "disabled is required"})
|
|
return
|
|
}
|
|
|
|
ctx := c.Request.Context()
|
|
|
|
targetAuth, _ := h.lookupAuthFile(name, authIndex)
|
|
if targetAuth == nil {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "auth file not found"})
|
|
return
|
|
}
|
|
if coreauth.IsPluginVirtualAuth(targetAuth) {
|
|
// Allow status changes only when targeting the source auth file name, matching delete semantics.
|
|
// Expanded virtual project auths still cannot be modified independently.
|
|
if !isPluginVirtualSourceDelete(name, targetAuth) {
|
|
c.JSON(http.StatusConflict, gin.H{"error": errPluginVirtualAuth.Error()})
|
|
return
|
|
}
|
|
if errPatch := h.patchPluginVirtualSourceStatus(ctx, targetAuth, *req.Disabled); errPatch != nil {
|
|
status := http.StatusInternalServerError
|
|
if errors.Is(errPatch, errAuthFileNotFound) || os.IsNotExist(errPatch) {
|
|
status = http.StatusNotFound
|
|
}
|
|
c.JSON(status, gin.H{"error": errPatch.Error()})
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{"status": "ok", "disabled": *req.Disabled})
|
|
return
|
|
}
|
|
|
|
if coreauth.IsConfigAPIKeyAuth(targetAuth) {
|
|
h.mu.Lock()
|
|
handled, errToggle := toggleConfigAPIKeyExcludedAll(h.cfg, targetAuth, *req.Disabled)
|
|
if errToggle != nil {
|
|
h.mu.Unlock()
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to update config api key: %v", errToggle)})
|
|
return
|
|
}
|
|
if !handled {
|
|
h.mu.Unlock()
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "config api key entry not found"})
|
|
return
|
|
}
|
|
cfgSnapshot, okSnapshot := h.saveConfigAndSnapshotLocked(c)
|
|
h.mu.Unlock()
|
|
if !okSnapshot {
|
|
return
|
|
}
|
|
h.reloadConfigAfterManagementSave(ctx, cfgSnapshot)
|
|
if h.tokenStore != nil {
|
|
_ = h.tokenStore.Delete(ctx, targetAuth.ID)
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"status": "ok",
|
|
"disabled": *req.Disabled,
|
|
"via": "config:excluded-models",
|
|
"excluded_pattern": configAPIKeyDisablePattern,
|
|
})
|
|
return
|
|
}
|
|
|
|
applyAuthDisabledState(targetAuth, *req.Disabled)
|
|
if _, err := h.authManager.Update(ctx, targetAuth); err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to update auth: %v", err)})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{"status": "ok", "disabled": *req.Disabled})
|
|
}
|
|
|
|
// patchPluginVirtualSourceStatus toggles disabled on a plugin multi-auth source file and all
|
|
// runtime auths expanded from it. Virtual project children cannot be toggled independently.
|
|
func (h *Handler) patchPluginVirtualSourceStatus(ctx context.Context, targetAuth *coreauth.Auth, disabled bool) error {
|
|
if h == nil || h.authManager == nil || targetAuth == nil {
|
|
return fmt.Errorf("core auth manager unavailable")
|
|
}
|
|
sourcePath := strings.TrimSpace(authAttribute(targetAuth, coreauth.AttributeVirtualSource))
|
|
if sourcePath == "" {
|
|
sourcePath = strings.TrimSpace(authAttribute(targetAuth, "path"))
|
|
}
|
|
if sourcePath == "" {
|
|
return errPluginVirtualAuth
|
|
}
|
|
if errWrite := setSourceAuthFileDisabled(sourcePath, disabled); errWrite != nil {
|
|
if os.IsNotExist(errWrite) {
|
|
return errAuthFileNotFound
|
|
}
|
|
return fmt.Errorf("failed to update source auth file: %w", errWrite)
|
|
}
|
|
now := time.Now()
|
|
for _, auth := range h.authManager.List() {
|
|
if auth == nil {
|
|
continue
|
|
}
|
|
if !sameAuthFilePath(authAttribute(auth, "path"), sourcePath) &&
|
|
!sameAuthFilePath(authAttribute(auth, coreauth.AttributeVirtualSource), sourcePath) {
|
|
continue
|
|
}
|
|
applyAuthDisabledState(auth, disabled)
|
|
auth.UpdatedAt = now
|
|
if _, errUpdate := h.authManager.Update(ctx, auth); errUpdate != nil {
|
|
return fmt.Errorf("failed to update auth %s: %w", auth.ID, errUpdate)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func setSourceAuthFileDisabled(path string, disabled bool) error {
|
|
path = strings.TrimSpace(path)
|
|
if path == "" {
|
|
return fmt.Errorf("source auth path is empty")
|
|
}
|
|
data, errRead := os.ReadFile(path)
|
|
if errRead != nil {
|
|
return errRead
|
|
}
|
|
metadata := make(map[string]any)
|
|
if len(bytes.TrimSpace(data)) > 0 {
|
|
if errUnmarshal := json.Unmarshal(data, &metadata); errUnmarshal != nil {
|
|
return fmt.Errorf("invalid auth file: %w", errUnmarshal)
|
|
}
|
|
}
|
|
if metadata == nil {
|
|
metadata = make(map[string]any)
|
|
}
|
|
coreauth.NormalizeCredentialMetadata(metadata)
|
|
metadata["disabled"] = disabled
|
|
raw, errMarshal := json.Marshal(metadata)
|
|
if errMarshal != nil {
|
|
return fmt.Errorf("marshal auth file: %w", errMarshal)
|
|
}
|
|
if errWrite := os.WriteFile(path, raw, 0o600); errWrite != nil {
|
|
return errWrite
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func applyAuthDisabledState(auth *coreauth.Auth, disabled bool) {
|
|
if auth == nil {
|
|
return
|
|
}
|
|
auth.Disabled = disabled
|
|
if disabled {
|
|
auth.Status = coreauth.StatusDisabled
|
|
auth.StatusMessage = "disabled via management API"
|
|
} else {
|
|
auth.Status = coreauth.StatusActive
|
|
auth.StatusMessage = ""
|
|
}
|
|
auth.UpdatedAt = time.Now()
|
|
if auth.Metadata == nil {
|
|
auth.Metadata = make(map[string]any)
|
|
}
|
|
auth.Metadata["disabled"] = disabled
|
|
}
|
|
|
|
// PatchAuthFileFields updates arbitrary metadata fields of an auth file.
|
|
func (h *Handler) PatchAuthFileFields(c *gin.Context) {
|
|
if h.authManager == nil {
|
|
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "core auth manager unavailable"})
|
|
return
|
|
}
|
|
|
|
var req map[string]json.RawMessage
|
|
decoder := json.NewDecoder(c.Request.Body)
|
|
decoder.UseNumber()
|
|
if err := decoder.Decode(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"})
|
|
return
|
|
}
|
|
|
|
nameRaw, ok := req["name"]
|
|
if !ok {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "name is required"})
|
|
return
|
|
}
|
|
var nameValue string
|
|
if err := json.Unmarshal(nameRaw, &nameValue); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "name is required"})
|
|
return
|
|
}
|
|
name := strings.TrimSpace(nameValue)
|
|
if name == "" {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "name is required"})
|
|
return
|
|
}
|
|
delete(req, "name")
|
|
var errNormalize error
|
|
req, errNormalize = normalizeAuthFilePatchFields(req)
|
|
if errNormalize != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": errNormalize.Error()})
|
|
return
|
|
}
|
|
requestRetryPatch, errRequestRetry := decodeAuthFileRequestRetryPatch(req)
|
|
if errRequestRetry != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": errRequestRetry.Error()})
|
|
return
|
|
}
|
|
for key := range req {
|
|
if strings.TrimSpace(key) == "request_retry" {
|
|
delete(req, key)
|
|
}
|
|
}
|
|
|
|
ctx := c.Request.Context()
|
|
|
|
// Find auth by name or ID
|
|
var targetAuth *coreauth.Auth
|
|
if auth, ok := h.authManager.GetByID(name); ok {
|
|
targetAuth = auth
|
|
} else {
|
|
auths := h.authManager.List()
|
|
for _, auth := range auths {
|
|
if auth.FileName == name {
|
|
targetAuth = auth
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
if targetAuth == nil {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "auth file not found"})
|
|
return
|
|
}
|
|
if coreauth.IsPluginVirtualAuth(targetAuth) {
|
|
c.JSON(http.StatusConflict, gin.H{"error": errPluginVirtualAuth.Error()})
|
|
return
|
|
}
|
|
coreauth.NormalizeCredentialMetadata(targetAuth.Metadata)
|
|
|
|
changed := false
|
|
touchedRoots := make(map[string]struct{}, len(req))
|
|
for key, rawValue := range req {
|
|
fieldPath := strings.TrimSpace(key)
|
|
if fieldPath == "" {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "field name is required"})
|
|
return
|
|
}
|
|
value, errDecode := decodeAuthFileFieldValue(rawValue)
|
|
if errDecode != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("invalid field %s", fieldPath)})
|
|
return
|
|
}
|
|
if targetAuth.Metadata == nil {
|
|
targetAuth.Metadata = make(map[string]any)
|
|
}
|
|
|
|
if fieldPath == coreauth.AttributeWeight {
|
|
if value == nil {
|
|
delete(targetAuth.Metadata, coreauth.AttributeWeight)
|
|
} else {
|
|
if _, okNumber := value.(json.Number); !okNumber {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "weight must be an integer"})
|
|
return
|
|
}
|
|
weight, errWeight := credentialweight.ParseValue(value)
|
|
if errWeight != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": errWeight.Error()})
|
|
return
|
|
}
|
|
targetAuth.Metadata[coreauth.AttributeWeight] = weight
|
|
}
|
|
} else if rootAuthFileField(fieldPath) == coreauth.AttributeWeight {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "weight does not support nested fields"})
|
|
return
|
|
} else if fieldPath == "headers" {
|
|
applyAuthFileHeadersPatch(targetAuth, value)
|
|
} else if errSet := setAuthFileMetadataValue(targetAuth.Metadata, fieldPath, value); errSet != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": errSet.Error()})
|
|
return
|
|
}
|
|
if root := rootAuthFileField(fieldPath); root != "" {
|
|
touchedRoots[root] = struct{}{}
|
|
}
|
|
changed = true
|
|
}
|
|
if requestRetryPatch.Set {
|
|
if targetAuth.Metadata == nil {
|
|
targetAuth.Metadata = make(map[string]any)
|
|
}
|
|
if requestRetryPatch.Value == nil {
|
|
delete(targetAuth.Metadata, "request_retry")
|
|
} else {
|
|
targetAuth.Metadata["request_retry"] = *requestRetryPatch.Value
|
|
}
|
|
changed = true
|
|
}
|
|
if changed {
|
|
syncAuthFileMetadataFields(targetAuth, touchedRoots)
|
|
}
|
|
|
|
if !changed {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "no fields to update"})
|
|
return
|
|
}
|
|
|
|
targetAuth.UpdatedAt = time.Now()
|
|
|
|
if _, err := h.authManager.Update(ctx, targetAuth); err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to update auth: %v", err)})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{"status": "ok"})
|
|
}
|
|
|
|
func decodeAuthFileFieldValue(raw json.RawMessage) (any, error) {
|
|
decoder := json.NewDecoder(bytes.NewReader(raw))
|
|
decoder.UseNumber()
|
|
var value any
|
|
if err := decoder.Decode(&value); err != nil {
|
|
return nil, err
|
|
}
|
|
return value, nil
|
|
}
|
|
|
|
type authFileRequestRetryPatch struct {
|
|
Set bool
|
|
Value *int
|
|
}
|
|
|
|
func normalizeAuthFilePatchFields(fields map[string]json.RawMessage) (map[string]json.RawMessage, error) {
|
|
normalized := make(map[string]json.RawMessage, len(fields))
|
|
originalNames := make(map[string]string, len(fields))
|
|
canonicalNames := make(map[string]bool, len(fields))
|
|
for key, value := range fields {
|
|
parts := strings.Split(strings.TrimSpace(key), ".")
|
|
for index := range parts {
|
|
parts[index] = strings.TrimSpace(parts[index])
|
|
}
|
|
originalRoot := parts[0]
|
|
parts[0] = coreauth.CanonicalCredentialMetadataKey(originalRoot)
|
|
canonicalPath := strings.Join(parts, ".")
|
|
if original, exists := originalNames[canonicalPath]; exists {
|
|
currentCanonical := originalRoot == parts[0]
|
|
if canonicalNames[canonicalPath] != currentCanonical {
|
|
if currentCanonical {
|
|
normalized[canonicalPath] = value
|
|
originalNames[canonicalPath] = key
|
|
canonicalNames[canonicalPath] = true
|
|
}
|
|
continue
|
|
}
|
|
return nil, fmt.Errorf("auth file fields %q and %q refer to the same field", original, key)
|
|
}
|
|
normalized[canonicalPath] = value
|
|
originalNames[canonicalPath] = key
|
|
canonicalNames[canonicalPath] = originalRoot == parts[0]
|
|
}
|
|
return normalized, nil
|
|
}
|
|
|
|
func decodeAuthFileRequestRetryPatch(fields map[string]json.RawMessage) (authFileRequestRetryPatch, error) {
|
|
var raw json.RawMessage
|
|
found := false
|
|
for key, value := range fields {
|
|
fieldPath := strings.TrimSpace(key)
|
|
fieldRoot := rootAuthFileField(fieldPath)
|
|
if fieldRoot == "request_retry" && fieldPath != fieldRoot {
|
|
return authFileRequestRetryPatch{}, fmt.Errorf("request_retry does not support nested fields")
|
|
}
|
|
if fieldPath == "request_retry" {
|
|
found = true
|
|
raw = value
|
|
}
|
|
}
|
|
if !found {
|
|
return authFileRequestRetryPatch{}, nil
|
|
}
|
|
value, errDecode := decodeAuthFileFieldValue(raw)
|
|
if errDecode != nil {
|
|
return authFileRequestRetryPatch{}, fmt.Errorf("request_retry must be an integer or null")
|
|
}
|
|
if value == nil {
|
|
return authFileRequestRetryPatch{Set: true}, nil
|
|
}
|
|
number, okNumber := value.(json.Number)
|
|
if !okNumber {
|
|
return authFileRequestRetryPatch{}, fmt.Errorf("request_retry must be an integer or null")
|
|
}
|
|
parsed, errInt := number.Int64()
|
|
if errInt != nil {
|
|
return authFileRequestRetryPatch{}, fmt.Errorf("request_retry must be an integer or null")
|
|
}
|
|
normalized := int(parsed)
|
|
if int64(normalized) != parsed {
|
|
return authFileRequestRetryPatch{}, fmt.Errorf("request_retry must be an integer or null")
|
|
}
|
|
if normalized < 0 {
|
|
return authFileRequestRetryPatch{Set: true}, nil
|
|
}
|
|
return authFileRequestRetryPatch{Set: true, Value: &normalized}, nil
|
|
}
|
|
|
|
func rootAuthFileField(path string) string {
|
|
path = strings.TrimSpace(path)
|
|
if path == "" {
|
|
return ""
|
|
}
|
|
if idx := strings.Index(path, "."); idx >= 0 {
|
|
return strings.TrimSpace(path[:idx])
|
|
}
|
|
return path
|
|
}
|
|
|
|
func setAuthFileMetadataValue(metadata map[string]any, path string, value any) error {
|
|
if metadata == nil {
|
|
return fmt.Errorf("metadata is nil")
|
|
}
|
|
parts := strings.Split(path, ".")
|
|
current := metadata
|
|
for i, rawPart := range parts {
|
|
part := strings.TrimSpace(rawPart)
|
|
if part == "" {
|
|
return fmt.Errorf("invalid field path: %s", path)
|
|
}
|
|
if i == len(parts)-1 {
|
|
current[part] = value
|
|
return nil
|
|
}
|
|
next, ok := current[part].(map[string]any)
|
|
if !ok {
|
|
next = make(map[string]any)
|
|
current[part] = next
|
|
}
|
|
current = next
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func applyAuthFileHeadersPatch(auth *coreauth.Auth, value any) {
|
|
if auth == nil {
|
|
return
|
|
}
|
|
if auth.Metadata == nil {
|
|
auth.Metadata = make(map[string]any)
|
|
}
|
|
headersPatch, ok := authFileHeadersStringMap(value)
|
|
if !ok {
|
|
auth.Metadata["headers"] = value
|
|
return
|
|
}
|
|
|
|
existingHeaders := coreauth.ExtractCustomHeadersFromMetadata(auth.Metadata)
|
|
nextHeaders := make(map[string]string, len(existingHeaders))
|
|
for key, val := range existingHeaders {
|
|
nextHeaders[key] = val
|
|
}
|
|
for key, value := range headersPatch {
|
|
name := strings.TrimSpace(key)
|
|
if name == "" {
|
|
continue
|
|
}
|
|
val := strings.TrimSpace(value)
|
|
if val == "" {
|
|
delete(nextHeaders, name)
|
|
continue
|
|
}
|
|
nextHeaders[name] = val
|
|
}
|
|
|
|
if len(nextHeaders) == 0 {
|
|
delete(auth.Metadata, "headers")
|
|
return
|
|
}
|
|
metaHeaders := make(map[string]any, len(nextHeaders))
|
|
for key, value := range nextHeaders {
|
|
metaHeaders[key] = value
|
|
}
|
|
auth.Metadata["headers"] = metaHeaders
|
|
}
|
|
|
|
func authFileHeadersStringMap(value any) (map[string]string, bool) {
|
|
switch typed := value.(type) {
|
|
case map[string]string:
|
|
return typed, true
|
|
case map[string]any:
|
|
out := make(map[string]string, len(typed))
|
|
for key, rawValue := range typed {
|
|
value, ok := rawValue.(string)
|
|
if !ok {
|
|
return nil, false
|
|
}
|
|
out[key] = value
|
|
}
|
|
return out, true
|
|
default:
|
|
return nil, false
|
|
}
|
|
}
|
|
|
|
func syncAuthFileMetadataFields(auth *coreauth.Auth, touchedRoots map[string]struct{}) {
|
|
if auth == nil || len(touchedRoots) == 0 {
|
|
return
|
|
}
|
|
if _, ok := touchedRoots["prefix"]; ok {
|
|
if prefix, okString := auth.Metadata["prefix"].(string); okString {
|
|
auth.Prefix = strings.TrimSpace(prefix)
|
|
}
|
|
}
|
|
if _, ok := touchedRoots["proxy_url"]; ok {
|
|
if proxyURL, okString := auth.Metadata["proxy_url"].(string); okString {
|
|
auth.ProxyURL = strings.TrimSpace(proxyURL)
|
|
}
|
|
}
|
|
if _, ok := touchedRoots["headers"]; ok {
|
|
syncAuthFileHeaderAttributes(auth)
|
|
}
|
|
if _, ok := touchedRoots["priority"]; ok {
|
|
syncAuthFilePriorityAttribute(auth)
|
|
}
|
|
if _, ok := touchedRoots[coreauth.AttributeWeight]; ok {
|
|
syncAuthFileWeightAttribute(auth)
|
|
}
|
|
if _, ok := touchedRoots["note"]; ok {
|
|
syncAuthFileNoteAttribute(auth)
|
|
}
|
|
if _, ok := touchedRoots["websockets"]; ok {
|
|
syncAuthFileWebsocketsAttribute(auth)
|
|
}
|
|
if _, ok := touchedRoots["disabled"]; ok {
|
|
syncAuthFileDisabledState(auth)
|
|
}
|
|
}
|
|
|
|
func syncAuthFileHeaderAttributes(auth *coreauth.Auth) {
|
|
if auth == nil {
|
|
return
|
|
}
|
|
if auth.Attributes == nil {
|
|
auth.Attributes = make(map[string]string)
|
|
}
|
|
for key := range auth.Attributes {
|
|
if strings.HasPrefix(key, "header:") {
|
|
delete(auth.Attributes, key)
|
|
}
|
|
}
|
|
for name, value := range coreauth.ExtractCustomHeadersFromMetadata(auth.Metadata) {
|
|
auth.Attributes["header:"+name] = value
|
|
}
|
|
}
|
|
|
|
func syncAuthFilePriorityAttribute(auth *coreauth.Auth) {
|
|
if auth == nil {
|
|
return
|
|
}
|
|
if auth.Attributes == nil {
|
|
auth.Attributes = make(map[string]string)
|
|
}
|
|
priority, ok := authFileIntValue(auth.Metadata["priority"])
|
|
if !ok {
|
|
delete(auth.Attributes, "priority")
|
|
return
|
|
}
|
|
if priority == 0 {
|
|
delete(auth.Attributes, "priority")
|
|
return
|
|
}
|
|
auth.Attributes["priority"] = strconv.Itoa(priority)
|
|
}
|
|
|
|
func syncAuthFileWeightAttribute(auth *coreauth.Auth) {
|
|
if auth == nil {
|
|
return
|
|
}
|
|
if auth.Attributes == nil {
|
|
auth.Attributes = make(map[string]string)
|
|
}
|
|
weight, errWeight := credentialweight.ParseValue(auth.Metadata[coreauth.AttributeWeight])
|
|
if errWeight != nil {
|
|
delete(auth.Attributes, coreauth.AttributeWeight)
|
|
return
|
|
}
|
|
auth.Attributes[coreauth.AttributeWeight] = strconv.FormatInt(weight, 10)
|
|
}
|
|
|
|
func authFileIntValue(value any) (int, bool) {
|
|
switch typed := value.(type) {
|
|
case int:
|
|
return typed, true
|
|
case int64:
|
|
return int(typed), true
|
|
case float64:
|
|
return int(typed), true
|
|
case json.Number:
|
|
if i, err := typed.Int64(); err == nil {
|
|
return int(i), true
|
|
}
|
|
case string:
|
|
if i, err := strconv.Atoi(strings.TrimSpace(typed)); err == nil {
|
|
return i, true
|
|
}
|
|
}
|
|
return 0, false
|
|
}
|
|
|
|
func syncAuthFileNoteAttribute(auth *coreauth.Auth) {
|
|
if auth == nil {
|
|
return
|
|
}
|
|
if auth.Attributes == nil {
|
|
auth.Attributes = make(map[string]string)
|
|
}
|
|
note, ok := auth.Metadata["note"].(string)
|
|
if !ok {
|
|
delete(auth.Attributes, "note")
|
|
return
|
|
}
|
|
note = strings.TrimSpace(note)
|
|
if note == "" {
|
|
delete(auth.Attributes, "note")
|
|
return
|
|
}
|
|
auth.Attributes["note"] = note
|
|
}
|
|
|
|
func syncAuthFileWebsocketsAttribute(auth *coreauth.Auth) {
|
|
if auth == nil {
|
|
return
|
|
}
|
|
if auth.Attributes == nil {
|
|
auth.Attributes = make(map[string]string)
|
|
}
|
|
websockets, ok := authFileBoolValue(auth.Metadata["websockets"])
|
|
if !ok {
|
|
delete(auth.Attributes, "websockets")
|
|
return
|
|
}
|
|
auth.Attributes["websockets"] = strconv.FormatBool(websockets)
|
|
}
|
|
|
|
func authFileBoolValue(value any) (bool, bool) {
|
|
switch typed := value.(type) {
|
|
case bool:
|
|
return typed, true
|
|
case string:
|
|
parsed, errParse := strconv.ParseBool(strings.TrimSpace(typed))
|
|
if errParse == nil {
|
|
return parsed, true
|
|
}
|
|
}
|
|
return false, false
|
|
}
|
|
|
|
func syncAuthFileDisabledState(auth *coreauth.Auth) {
|
|
if auth == nil {
|
|
return
|
|
}
|
|
disabled, ok := authFileBoolValue(auth.Metadata["disabled"])
|
|
if !ok {
|
|
return
|
|
}
|
|
auth.Disabled = disabled
|
|
if disabled {
|
|
auth.Status = coreauth.StatusDisabled
|
|
if strings.TrimSpace(auth.StatusMessage) == "" {
|
|
auth.StatusMessage = "disabled via management API"
|
|
}
|
|
return
|
|
}
|
|
auth.Status = coreauth.StatusActive
|
|
auth.StatusMessage = ""
|
|
}
|
|
|
|
func (h *Handler) removeAuth(ctx context.Context, id string) {
|
|
if h == nil || h.authManager == nil {
|
|
return
|
|
}
|
|
id = strings.TrimSpace(id)
|
|
if id == "" {
|
|
return
|
|
}
|
|
if _, ok := h.authManager.GetByID(id); ok {
|
|
h.authManager.Remove(ctx, id)
|
|
return
|
|
}
|
|
authID := h.authIDForPath(id)
|
|
if authID == "" {
|
|
return
|
|
}
|
|
h.authManager.Remove(ctx, authID)
|
|
}
|
|
|
|
func (h *Handler) removeAuthsForPath(ctx context.Context, path string, fallbackID string) {
|
|
if h == nil || h.authManager == nil {
|
|
return
|
|
}
|
|
removed := false
|
|
for _, auth := range h.authManager.List() {
|
|
if auth == nil {
|
|
continue
|
|
}
|
|
if sameAuthFilePath(authAttribute(auth, "path"), path) || sameAuthFilePath(authAttribute(auth, coreauth.AttributeVirtualSource), path) {
|
|
h.removeAuth(ctx, auth.ID)
|
|
removed = true
|
|
}
|
|
}
|
|
if removed {
|
|
return
|
|
}
|
|
if strings.TrimSpace(fallbackID) != "" {
|
|
h.removeAuth(ctx, fallbackID)
|
|
return
|
|
}
|
|
h.removeAuth(ctx, path)
|
|
}
|
|
|
|
func sameAuthFilePath(left, right string) bool {
|
|
left = cleanAuthFilePath(left)
|
|
right = cleanAuthFilePath(right)
|
|
if left == "" || right == "" {
|
|
return false
|
|
}
|
|
if runtime.GOOS == "windows" {
|
|
return strings.EqualFold(left, right)
|
|
}
|
|
return left == right
|
|
}
|
|
|
|
func cleanAuthFilePath(path string) string {
|
|
path = strings.TrimSpace(path)
|
|
if path == "" {
|
|
return ""
|
|
}
|
|
if abs, errAbs := filepath.Abs(path); errAbs == nil && strings.TrimSpace(abs) != "" {
|
|
path = abs
|
|
}
|
|
return filepath.Clean(path)
|
|
}
|
|
|
|
func (h *Handler) deleteTokenRecord(ctx context.Context, path string) error {
|
|
if strings.TrimSpace(path) == "" {
|
|
return fmt.Errorf("auth path is empty")
|
|
}
|
|
store := h.tokenStoreWithBaseDir()
|
|
if store == nil {
|
|
return fmt.Errorf("token store unavailable")
|
|
}
|
|
return store.Delete(ctx, path)
|
|
}
|
|
|
|
func (h *Handler) tokenStoreWithBaseDir() coreauth.Store {
|
|
if h == nil {
|
|
return nil
|
|
}
|
|
store := h.tokenStore
|
|
if store == nil {
|
|
store = sdkAuth.GetTokenStore()
|
|
h.tokenStore = store
|
|
}
|
|
if h.cfg != nil {
|
|
if dirSetter, ok := store.(interface{ SetBaseDir(string) }); ok {
|
|
dirSetter.SetBaseDir(h.cfg.AuthDir)
|
|
}
|
|
}
|
|
return store
|
|
}
|
|
|
|
func (h *Handler) mergeExistingAuthFileMetadata(record *coreauth.Auth) {
|
|
if h == nil || record == nil {
|
|
return
|
|
}
|
|
var existingMap map[string]any
|
|
|
|
if h.cfg != nil && strings.TrimSpace(h.cfg.AuthDir) != "" {
|
|
targetFile := record.FileName
|
|
if targetFile == "" {
|
|
targetFile = record.ID
|
|
}
|
|
if targetFile != "" {
|
|
fullPath := filepath.Join(h.cfg.AuthDir, targetFile)
|
|
if raw, errRead := os.ReadFile(fullPath); errRead == nil && len(raw) > 0 {
|
|
_ = json.Unmarshal(raw, &existingMap)
|
|
}
|
|
}
|
|
}
|
|
|
|
if existingMap == nil && h.authManager != nil {
|
|
if existing, ok := h.authManager.GetByID(record.ID); ok && existing != nil && existing.Metadata != nil {
|
|
existingMap = existing.Metadata
|
|
} else {
|
|
for _, auth := range h.authManager.List() {
|
|
if auth != nil && auth.FileName == record.FileName && auth.Metadata != nil {
|
|
existingMap = auth.Metadata
|
|
break
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if len(existingMap) > 0 {
|
|
coreauth.MergeExistingAuthMetadata(record, existingMap)
|
|
}
|
|
}
|
|
|
|
func (h *Handler) saveTokenRecord(ctx context.Context, record *coreauth.Auth) (string, error) {
|
|
if record == nil {
|
|
return "", fmt.Errorf("token record is nil")
|
|
}
|
|
h.mergeExistingAuthFileMetadata(record)
|
|
store := h.tokenStoreWithBaseDir()
|
|
if store == nil {
|
|
return "", fmt.Errorf("token store unavailable")
|
|
}
|
|
if h.postAuthHook != nil {
|
|
if err := h.postAuthHook(ctx, record); err != nil {
|
|
return "", fmt.Errorf("post-auth hook failed: %w", err)
|
|
}
|
|
}
|
|
savedPath, errSave := store.Save(ctx, record)
|
|
if errSave != nil {
|
|
return savedPath, errSave
|
|
}
|
|
if h.postAuthPersistHook != nil {
|
|
if errHook := h.postAuthPersistHook(ctx, record); errHook != nil {
|
|
return savedPath, fmt.Errorf("post-auth persist hook failed: %w", errHook)
|
|
}
|
|
}
|
|
return savedPath, nil
|
|
}
|