From c88f77474936328b7268036bae342f651e58136c Mon Sep 17 00:00:00 2001 From: grendervill Date: Sun, 27 Sep 2026 17:59:49 +0300 Subject: [PATCH] =?UTF-8?q?feat(auth):=20VK=20ID=20=D1=80=D0=B0=D0=B1?= =?UTF-8?q?=D0=BE=D1=82=D0=B0=D0=B5=D1=82=20=D0=B8=20=D0=BA=D0=B0=D0=BA=20?= =?UTF-8?q?=D0=BF=D1=83=D0=B1=D0=BB=D0=B8=D1=87=D0=BD=D0=BE=D0=B5=20=D0=BF?= =?UTF-8?q?=D1=80=D0=B8=D0=BB=D0=BE=D0=B6=D0=B5=D0=BD=D0=B8=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Публичному приложению VK ID сервисный ключ не нужен: обмен кода защищает PKCE, и VK выдаёт токен по одному client_id. Раньше провайдер включался только при заполненных id и секрете, то есть публичный вариант был недоступен. - у провайдера появился флаг SecretOptional (у VK ID — true); - включение провайдера и каталог учитывают флаг; - тест TestOAuthVKPublicAppWithoutSecret: вход работает без секрета, в запросе обмена нет ни client_secret, ни service_token. --- internal/auth/oauth.go | 19 +++++++-- internal/auth/oauth_internal_test.go | 59 ++++++++++++++++++++++++++++ 2 files changed, 75 insertions(+), 3 deletions(-) diff --git a/internal/auth/oauth.go b/internal/auth/oauth.go index 926f636..88567b4 100644 --- a/internal/auth/oauth.go +++ b/internal/auth/oauth.go @@ -60,6 +60,9 @@ type oauthProvider struct { // DeviceIDParam — имя query-параметра, который провайдер возвращает вместе с // кодом и требует при обмене кода на токен (у VK ID это device_id). DeviceIDParam string + // SecretOptional — провайдер работает и без секрета (публичное приложение + // VK ID: обмен защищён PKCE, сервисный ключ нужен только конфиденциальным). + SecretOptional bool // TokenSecretParam — имя POST-параметра для секрета при обмене кода. // Пусто — секрет уходит обычной Basic-авторизацией (как у GitHub/Google). // У VK ID это service_token: он нужен конфиденциальным приложениям, а @@ -114,6 +117,7 @@ func defaultOAuthProviders() map[string]oauthProvider { // VK ID без PKCE не работает: code_challenge обязателен в запросе // авторизации, device_id возвращается в callback и нужен при обмене. PKCE: true, + SecretOptional: true, DeviceIDParam: "device_id", TokenSecretParam: "service_token", FetchProfile: fetchVKProfile, @@ -171,7 +175,10 @@ func (s *Service) oauthConfigs() map[string]oauthProvider { enabled := make(map[string]oauthProvider, len(providers)) for id, provider := range providers { credentials, ok := s.oauth[id] - if !ok || credentials.clientID == "" || credentials.clientSecret == "" { + if !ok || credentials.clientID == "" { + continue + } + if credentials.clientSecret == "" && !provider.SecretOptional { continue } provider.Scopes = append([]string{}, provider.Scopes...) @@ -847,9 +854,15 @@ func oauthGetJSON(ctx context.Context, client *http.Client, token *oauth2.Token, func oauthFromConfig(cfg config.Config, baseURL string) (map[string]oauthClientCredentials, string) { credentials := map[string]oauthClientCredentials{} add := func(id, clientID, clientSecret string) { - if clientID != "" && clientSecret != "" { - credentials[id] = oauthClientCredentials{clientID: clientID, clientSecret: clientSecret} + provider, known := oauthProviders()[id] + if clientID == "" { + return } + // Публичному приложению VK ID секрет не нужен: обмен защищает PKCE. + if clientSecret == "" && (!known || !provider.SecretOptional) { + return + } + credentials[id] = oauthClientCredentials{clientID: clientID, clientSecret: clientSecret} } add("vk", cfg.OAuthVKClientID, cfg.OAuthVKClientSecret) add("yandex", cfg.OAuthYandexClientID, cfg.OAuthYandexClientSecret) diff --git a/internal/auth/oauth_internal_test.go b/internal/auth/oauth_internal_test.go index fd31792..7620710 100644 --- a/internal/auth/oauth_internal_test.go +++ b/internal/auth/oauth_internal_test.go @@ -664,3 +664,62 @@ func TestOAuthYandexFlow(t *testing.T) { t.Fatalf("Authorization = %q, want `OAuth …`", infoAuth) } } + +// TestOAuthVKPublicAppWithoutSecret — публичному приложению VK ID сервисный +// ключ не нужен: вход включается по одному client_id, а запрос обмена не несёт +// ни client_secret, ни service_token (защиту даёт PKCE). +func TestOAuthVKPublicAppWithoutSecret(t *testing.T) { + originalProviders := oauthProviderSet + originalInfoURL := vkUserInfoURL + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + w.WriteHeader(http.StatusBadRequest) + return + } + if r.PostForm.Get("service_token") != "" || r.PostForm.Get("client_secret") != "" { + t.Errorf("публичное приложение не должно отправлять секрет: %v", r.PostForm) + } + w.Header().Set("Content-Type", "application/json") + if strings.HasSuffix(r.URL.Path, "/user_info") { + _ = json.NewEncoder(w).Encode(map[string]any{ + "user": map[string]any{"user_id": 7, "first_name": "Пётр", "email": "petr@example.com"}, + }) + return + } + _ = json.NewEncoder(w).Encode(map[string]any{"access_token": "vk-token", "token_type": "Bearer"}) + })) + t.Cleanup(server.Close) + + providers := defaultOAuthProviders() + vk := providers["vk"] + vk.Endpoint.TokenURL = server.URL + "/token" + providers["vk"] = vk + oauthProviderSet = providers + vkUserInfoURL = server.URL + "/user_info" + t.Cleanup(func() { + oauthProviderSet = originalProviders + vkUserInfoURL = originalInfoURL + }) + + cfg := oauthTestConfig() + cfg.OAuthVKClientID = "vk-public" + cfg.OAuthVKClientSecret = "" + service, _ := oauthTestService(t, cfg) + if !service.OAuthEnabled() { + t.Fatal("вход через VK должен работать без сервисного ключа") + } + state, err := service.signOAuthState("vk", "/app") + if err != nil { + t.Fatalf("sign state: %v", err) + } + ctx := context.Background() + user, _, _, _, err := service.OAuthCallback(ctx, "vk", "code", state, + url.Values{"device_id": {"device-1"}}, "127.0.0.1", "agent") + if err != nil { + t.Fatalf("OAuthCallback: %v", err) + } + email, err := service.Email(ctx, user.ID) + if err != nil || email != "petr@example.com" { + t.Fatalf("email = %q, err = %v", email, err) + } +}