feat(auth): VK ID работает и как публичное приложение

Публичному приложению VK ID сервисный ключ не нужен: обмен кода защищает PKCE,
и VK выдаёт токен по одному client_id. Раньше провайдер включался только при
заполненных id и секрете, то есть публичный вариант был недоступен.

- у провайдера появился флаг SecretOptional (у VK ID — true);
- включение провайдера и каталог учитывают флаг;
- тест TestOAuthVKPublicAppWithoutSecret: вход работает без секрета, в запросе
  обмена нет ни client_secret, ни service_token.
This commit is contained in:
2026-09-27 17:59:49 +03:00
parent 00d86bb70c
commit c88f774749
2 changed files with 75 additions and 3 deletions
+16 -3
View File
@@ -60,6 +60,9 @@ type oauthProvider struct {
// DeviceIDParam — имя query-параметра, который провайдер возвращает вместе с // DeviceIDParam — имя query-параметра, который провайдер возвращает вместе с
// кодом и требует при обмене кода на токен (у VK ID это device_id). // кодом и требует при обмене кода на токен (у VK ID это device_id).
DeviceIDParam string DeviceIDParam string
// SecretOptional — провайдер работает и без секрета (публичное приложение
// VK ID: обмен защищён PKCE, сервисный ключ нужен только конфиденциальным).
SecretOptional bool
// TokenSecretParam — имя POST-параметра для секрета при обмене кода. // TokenSecretParam — имя POST-параметра для секрета при обмене кода.
// Пусто — секрет уходит обычной Basic-авторизацией (как у GitHub/Google). // Пусто — секрет уходит обычной Basic-авторизацией (как у GitHub/Google).
// У VK ID это service_token: он нужен конфиденциальным приложениям, а // У VK ID это service_token: он нужен конфиденциальным приложениям, а
@@ -114,6 +117,7 @@ func defaultOAuthProviders() map[string]oauthProvider {
// VK ID без PKCE не работает: code_challenge обязателен в запросе // VK ID без PKCE не работает: code_challenge обязателен в запросе
// авторизации, device_id возвращается в callback и нужен при обмене. // авторизации, device_id возвращается в callback и нужен при обмене.
PKCE: true, PKCE: true,
SecretOptional: true,
DeviceIDParam: "device_id", DeviceIDParam: "device_id",
TokenSecretParam: "service_token", TokenSecretParam: "service_token",
FetchProfile: fetchVKProfile, FetchProfile: fetchVKProfile,
@@ -171,7 +175,10 @@ func (s *Service) oauthConfigs() map[string]oauthProvider {
enabled := make(map[string]oauthProvider, len(providers)) enabled := make(map[string]oauthProvider, len(providers))
for id, provider := range providers { for id, provider := range providers {
credentials, ok := s.oauth[id] credentials, ok := s.oauth[id]
if !ok || credentials.clientID == "" || credentials.clientSecret == "" { if !ok || credentials.clientID == "" {
continue
}
if credentials.clientSecret == "" && !provider.SecretOptional {
continue continue
} }
provider.Scopes = append([]string{}, provider.Scopes...) 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) { func oauthFromConfig(cfg config.Config, baseURL string) (map[string]oauthClientCredentials, string) {
credentials := map[string]oauthClientCredentials{} credentials := map[string]oauthClientCredentials{}
add := func(id, clientID, clientSecret string) { add := func(id, clientID, clientSecret string) {
if clientID != "" && clientSecret != "" { provider, known := oauthProviders()[id]
credentials[id] = oauthClientCredentials{clientID: clientID, clientSecret: clientSecret} 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("vk", cfg.OAuthVKClientID, cfg.OAuthVKClientSecret)
add("yandex", cfg.OAuthYandexClientID, cfg.OAuthYandexClientSecret) add("yandex", cfg.OAuthYandexClientID, cfg.OAuthYandexClientSecret)
+59
View File
@@ -664,3 +664,62 @@ func TestOAuthYandexFlow(t *testing.T) {
t.Fatalf("Authorization = %q, want `OAuth …`", infoAuth) 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)
}
}