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:
+16
-3
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user