// Изменено: 2026-04-10 // jwt.go — JWT-парсинг и валидация через nubes API (deck-api). // Извлекает sub (UUID пользователя) и email из JWT claims. // Валидация: GET к nubes API с Bearer token → если 401/403 → отклонён. // Подпись JWT НЕ проверяется — аналогично sless (trusted perimeter). package auth import ( "context" "encoding/base64" "encoding/json" "errors" "fmt" "net/http" "strings" "time" ) // JWTClaims — минимальный набор claims из nubes JWT. // Не все поля заполнены у каждого пользователя — email может быть пустым. type JWTClaims struct { Sub string `json:"sub"` Email string `json:"email"` Exp int64 `json:"exp"` Iss string `json:"iss"` } // ParseJWTClaims — декодирует JWT payload (base64url) и возвращает claims. // Проверяет: структуру JWT (3 части), наличие sub, не истёк ли exp. // Подпись НЕ проверяется — валидация через PingNubesAPI. func ParseJWTClaims(token string) (*JWTClaims, error) { parts := strings.Split(token, ".") if len(parts) != 3 { return nil, fmt.Errorf("invalid JWT: expected 3 parts, got %d", len(parts)) } // JWT использует base64url без padding — добавляем payload := parts[1] switch len(payload) % 4 { case 2: payload += "==" case 3: payload += "=" } decoded, err := base64.URLEncoding.DecodeString(payload) if err != nil { // Пробуем StdEncoding на случай нестандартного токена decoded, err = base64.StdEncoding.DecodeString(payload) if err != nil { return nil, fmt.Errorf("decode JWT payload: %w", err) } } var claims JWTClaims if err := json.Unmarshal(decoded, &claims); err != nil { return nil, fmt.Errorf("parse JWT claims: %w", err) } if claims.Sub == "" { return nil, fmt.Errorf("JWT missing 'sub' claim") } if claims.Exp == 0 { return nil, fmt.Errorf("JWT missing 'exp' claim") } if claims.Exp < time.Now().Unix() { return nil, fmt.Errorf("JWT token expired") } return &claims, nil } // ErrTokenRejected — шлюз явно отклонил токен (401/403). // Sentinel, чтобы отличать отказ от сетевых ошибок (см. cache.go). var ErrTokenRejected = errors.New("token rejected by nubes API") // nubesAPIEndpoints — стенды Nubes API Gateway, по которым валидируется токен. // Токен принимается, если ХОТЯ БЫ ОДИН стенд его принял. // Безопасность: только prod-шлюз. Тестовый/dev-шлюзы принимают токены своих // стендов, и при их наличии токен тестового стенда считался бы валидным на проде. // Специфичный endpoint можно переопределить через NUBES_ENDPOINT. var nubesAPIEndpoints = []string{ "https://lk-api-gateway.ngcloud.ru/api/v1/svc", } // nubesHTTPClient — общий клиент для валидации токенов: переиспользует // соединения (keep-alive) вместо создания нового клиента на каждый запрос. var nubesHTTPClient = &http.Client{Timeout: 10 * time.Second} // PingNubesAPI — проверяет валидность токена перебором стендов Nubes API. // endpoint (если задан через NUBES_ENDPOINT) пробуется первым. // Логика: токен принимается ТОЛЬКО при явном HTTP 2xx от стенда. // 401/403 → токен отклонён ЭТИМ стендом (ErrTokenRejected), пробуем следующий. // Любой другой статус (4xx/5xx) → стенд НЕ подтвердил токен, пробуем следующий. func PingNubesAPI(ctx context.Context, endpoint, token string) error { candidates := make([]string, 0, len(nubesAPIEndpoints)+1) if endpoint != "" { candidates = append(candidates, endpoint) } for _, ep := range nubesAPIEndpoints { if ep != endpoint { candidates = append(candidates, ep) } } var lastErr error for _, ep := range candidates { req, err := http.NewRequestWithContext(ctx, http.MethodGet, ep, nil) if err != nil { lastErr = fmt.Errorf("build nubes ping request: %w", err) continue } req.Header.Set("Authorization", "Bearer "+token) req.Header.Set("User-Agent", "Mozilla/5.0") resp, err := nubesHTTPClient.Do(req) if err != nil { lastErr = fmt.Errorf("nubes API unreachable at %s: %w", ep, err) continue } resp.Body.Close() // Принимаем токен ТОЛЬКО при явном 2xx: никакой другой статус (5xx, // 429, редирект и т.п.) не должен считаться подтверждением валидности. if resp.StatusCode >= http.StatusOK && resp.StatusCode < http.StatusMultipleChoices { return nil } if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden { lastErr = fmt.Errorf("%w (HTTP %d)", ErrTokenRejected, resp.StatusCode) continue } lastErr = fmt.Errorf("nubes API %s returned unexpected status HTTP %d", ep, resp.StatusCode) } if lastErr == nil { lastErr = fmt.Errorf("nubes API rejected token") } return lastErr }