00d86bb70c
Владельцу нужны VK и Яндекс (Google/Discord/GitHub остаются в каталоге и включаются своими ключами). VK ID (id.vk.ru, OAuth 2.1): - обязательный PKCE: code_challenge в запросе авторизации, code_verifier при обмене; верификатор выводится из подписанного state (HMAC), хранить нечего; - device_id из callback уходит в обмен кода; - для конфиденциальных приложений секрет передаётся как service_token, а не client_secret (и обмен идёт параметрами в теле, не Basic); - профиль: POST /oauth2/user_info с client_id и access_token; права email и vkid.personal_info. Яндекс ID: - права login:email и login:info; секрет — в теле запроса обмена; - профиль: GET login.yandex.ru/info?format=json с заголовком "Authorization: OAuth <токен>" (не Bearer). Общее: - в профиль добавлено DisplayName: VK отдаёт имя и фамилию, Яндекс — real_name, раньше они терялись; - почта считается подтверждённой, если провайдер её отдал (отдельного флага нет, адрес приходит только по соответствующему праву) — D-083; - новые переменные OAUTH_VK_* и OAUTH_YANDEX_* в установщике и .env.example; - тесты: полные флоу обоих провайдеров на мок-серверах, каталог и ручки.
93 lines
3.7 KiB
Go
93 lines
3.7 KiB
Go
package server
|
|
|
|
import (
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// TestOAuthProviderNotConfigured — ручка входа через провайдера отвечает
|
|
// понятной ошибкой, пока оператор не задал OAUTH_* (Фаза 7).
|
|
func TestOAuthProviderNotConfigured(t *testing.T) {
|
|
srv, _ := newTestServer(t)
|
|
|
|
rec := doJSON(t, srv, http.MethodGet, "/api/v1/auth/oauth/github/start", "")
|
|
if rec.Code != http.StatusNotFound {
|
|
t.Fatalf("oauth start without config = %d, want 404, body = %s", rec.Code, rec.Body.String())
|
|
}
|
|
if code := errorCodeOf(t, rec); code != "oauth.provider_not_configured" {
|
|
t.Fatalf("error code = %q, want oauth.provider_not_configured", code)
|
|
}
|
|
|
|
// VK и Яндекс есть в каталоге, но без ключей тоже отвечают «не настроен».
|
|
for _, provider := range []string{"vk", "yandex"} {
|
|
rec = doJSON(t, srv, http.MethodGet, "/api/v1/auth/oauth/"+provider+"/start", "")
|
|
if rec.Code != http.StatusNotFound {
|
|
t.Fatalf("%s start without config = %d, want 404", provider, rec.Code)
|
|
}
|
|
if code := errorCodeOf(t, rec); code != "oauth.provider_not_configured" {
|
|
t.Fatalf("%s error code = %q, want oauth.provider_not_configured", provider, code)
|
|
}
|
|
}
|
|
|
|
rec = doJSON(t, srv, http.MethodGet, "/api/v1/auth/oauth/ok/start", "")
|
|
if rec.Code != http.StatusNotFound {
|
|
t.Fatalf("unknown provider = %d, want 404", rec.Code)
|
|
}
|
|
if code := errorCodeOf(t, rec); code != "oauth.provider_unknown" {
|
|
t.Fatalf("error code = %q, want oauth.provider_unknown", code)
|
|
}
|
|
|
|
// Список провайдеров пуст, клиент не рисует кнопки.
|
|
rec = doJSON(t, srv, http.MethodGet, "/api/v1/auth/oauth/providers", "")
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("providers list = %d, body = %s", rec.Code, rec.Body.String())
|
|
}
|
|
var payload struct {
|
|
Providers []struct {
|
|
ID string `json:"id"`
|
|
} `json:"providers"`
|
|
RedirectURL string `json:"redirect_url"`
|
|
}
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil {
|
|
t.Fatalf("decode providers: %v", err)
|
|
}
|
|
if len(payload.Providers) != 0 {
|
|
t.Fatalf("providers = %s, want empty", rec.Body.String())
|
|
}
|
|
|
|
// Мета инстанса честно сообщает, что провайдеры выключены.
|
|
meta := doJSON(t, srv, http.MethodGet, "/api/v1/meta", "")
|
|
if meta.Code != http.StatusOK {
|
|
t.Fatalf("meta = %d", meta.Code)
|
|
}
|
|
if !strings.Contains(meta.Body.String(), `"oauth_enabled":false`) {
|
|
t.Fatalf("meta must report oauth_enabled=false: %s", meta.Body.String())
|
|
}
|
|
if !strings.Contains(meta.Body.String(), `"passkeys_enabled":true`) {
|
|
t.Fatalf("meta must report passkeys_enabled=true for a domain: %s", meta.Body.String())
|
|
}
|
|
}
|
|
|
|
// TestOAuthCallbackRejectsUnsignedState — колбэк без валидного state
|
|
// возвращает пользователя на вход с кодом ошибки, а не создаёт сессию.
|
|
func TestOAuthCallbackRejectsUnsignedState(t *testing.T) {
|
|
srv, _ := newTestServer(t)
|
|
request := httptest.NewRequestWithContext(t.Context(), http.MethodGet,
|
|
"/api/v1/auth/oauth/github/callback?code=abc&state=forged", nil)
|
|
rec := httptest.NewRecorder()
|
|
srv.Handler().ServeHTTP(rec, request)
|
|
if rec.Code != http.StatusFound {
|
|
t.Fatalf("callback with forged state = %d, want 302, body = %s", rec.Code, rec.Body.String())
|
|
}
|
|
location := rec.Header().Get("Location")
|
|
if !strings.HasPrefix(location, "/login?oauth_error=") {
|
|
t.Fatalf("callback must redirect to the login page with a code, got %q", location)
|
|
}
|
|
if len(rec.Result().Cookies()) != 0 {
|
|
t.Fatal("failed oauth callback must not set a session cookie")
|
|
}
|
|
}
|