Files
glchat/internal/auth/oauth_internal_test.go
T
grendervill c88f774749 feat(auth): VK ID работает и как публичное приложение
Публичному приложению VK ID сервисный ключ не нужен: обмен кода защищает PKCE,
и VK выдаёт токен по одному client_id. Раньше провайдер включался только при
заполненных id и секрете, то есть публичный вариант был недоступен.

- у провайдера появился флаг SecretOptional (у VK ID — true);
- включение провайдера и каталог учитывают флаг;
- тест TestOAuthVKPublicAppWithoutSecret: вход работает без секрета, в запросе
  обмена нет ни client_secret, ни service_token.
2026-09-27 17:59:49 +03:00

726 lines
27 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package auth
import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"errors"
"log/slog"
"net/http"
"net/http/httptest"
"net/url"
"path/filepath"
"strconv"
"strings"
"testing"
"time"
"golang.org/x/oauth2"
"glchat/internal/config"
"glchat/internal/crypto"
"glchat/internal/database"
"glchat/internal/store"
)
// oauthTestConfig — конфиг тестового сервиса с включённым GitHub-провайдером.
func oauthTestConfig() config.Config {
return config.Config{
Domain: "gl.mhspx.su",
InstanceName: "glchat",
SessionPepper: "unit-test-session-pepper",
MasterKey: "00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff",
TOTPEncryptionKey: "unit-test-totp-key",
SessionTTLHours: 24,
// Низкие параметры Argon2id: тесты не должны работать секундами.
Argon2MemoryKiB: 1024,
Argon2Iterations: 1,
Argon2Parallelism: 1,
OAuthGitHubClientID: "client-id",
OAuthGitHubClientSecret: "client-secret",
}
}
// oauthTestBaseConfig — конфиг без OAuth: проверяем выключенные провайдеры.
func oauthTestBaseConfig() config.Config {
cfg := oauthTestConfig()
cfg.OAuthGitHubClientID = ""
cfg.OAuthGitHubClientSecret = ""
return cfg
}
// oauthTestServer поднимает фальшивого провайдера: обмен кода на токен и
// профиль. Адреса подставляются в каталог провайдеров на время теста.
func oauthTestServer(t *testing.T, profile oauthProfile) *httptest.Server {
t.Helper()
mux := http.NewServeMux()
mux.HandleFunc("/token", func(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
w.WriteHeader(http.StatusBadRequest)
return
}
if r.Form.Get("code") != "good-code" {
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write([]byte(`{"error":"invalid_grant"}`))
return
}
// Провайдер отвечает JSON: так же ведёт себя GitHub при
// `Accept: application/json`, который ставит golang.org/x/oauth2.
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"access_token": "test-access-token",
"token_type": "bearer",
"expires_in": 3600,
})
})
mux.HandleFunc("/profile", func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Authorization") != "Bearer test-access-token" {
w.WriteHeader(http.StatusUnauthorized)
return
}
_ = json.NewEncoder(w).Encode(map[string]any{"ok": true})
})
server := httptest.NewServer(mux)
t.Cleanup(server.Close)
original := oauthProviderSet
oauthProviderSet = map[string]oauthProvider{
"github": {
ID: "github",
Name: "GitHub",
Endpoint: oauth2.Endpoint{
AuthURL: server.URL + "/authorize",
TokenURL: server.URL + "/token",
},
Scopes: []string{"user:email"},
FetchProfile: func(ctx context.Context, _ *http.Client, token *oauth2.Token, _ string) (oauthProfile, error) {
// Провайдер получает токен так же, как в бою: через oauth2-клиент.
authorized := oauth2.NewClient(ctx, oauth2.StaticTokenSource(token))
request, err := http.NewRequestWithContext(ctx, http.MethodGet, server.URL+"/profile", nil)
if err != nil {
return oauthProfile{}, err
}
response, err := authorized.Do(request)
if err != nil {
return oauthProfile{}, err
}
defer func() { _ = response.Body.Close() }()
if response.StatusCode != http.StatusOK {
return oauthProfile{}, errors.New("profile request failed")
}
return profile, nil
},
},
}
t.Cleanup(func() { oauthProviderSet = original })
return server
}
func oauthTestService(t *testing.T, cfg config.Config) (*Service, *store.Store) {
t.Helper()
ctx := context.Background()
db, err := database.Open(ctx, database.Options{
Path: filepath.Join(t.TempDir(), "glchat.db"),
ReadPool: 2,
Migrate: true,
})
if err != nil {
t.Fatalf("open database: %v", err)
}
t.Cleanup(func() { _ = db.Close() })
st := store.New(db)
service, err := New(ctx, cfg, st, slog.New(slog.DiscardHandler))
if err != nil {
t.Fatalf("auth.New: %v", err)
}
return service, st
}
// TestOAuthNotConfigured — без клиентских id ручки отвечают понятной ошибкой,
// а не пытаются увести пользователя к провайдеру (AGENT.md 7.1).
func TestOAuthNotConfigured(t *testing.T) {
service, _ := oauthTestService(t, oauthTestBaseConfig())
if service.OAuthEnabled() {
t.Fatal("oauth must be disabled without client credentials")
}
if _, err := service.OAuthAuthorizeURL("github", "/app"); !errors.Is(err, ErrOAuthNotConfigured) {
t.Fatalf("authorize url without config: err = %v, want ErrOAuthNotConfigured", err)
}
if _, err := service.OAuthAuthorizeURL("ok", "/app"); !errors.Is(err, ErrOAuthUnknownProvider) {
t.Fatalf("unknown provider: err = %v, want ErrOAuthUnknownProvider", err)
}
// VK и Яндекс в каталоге есть, но без ключей отвечают «не настроен».
for _, provider := range []string{"vk", "yandex"} {
if _, err := service.OAuthAuthorizeURL(provider, "/app"); !errors.Is(err, ErrOAuthNotConfigured) {
t.Fatalf("%s without config: err = %v, want ErrOAuthNotConfigured", provider, err)
}
}
if len(service.OAuthProviders()) != 0 {
t.Fatalf("providers = %v, want empty", service.OAuthProviders())
}
}
// TestOAuthAuthorizeURL — ссылка ведёт к провайдеру и несёт подписанный state.
func TestOAuthAuthorizeURL(t *testing.T) {
oauthTestServer(t, oauthProfile{Subject: "1", Email: "user@example.com", EmailVerified: true})
service, _ := oauthTestService(t, oauthTestConfig())
providers := service.OAuthProviders()
if len(providers) != 1 || providers[0].ID != "github" {
t.Fatalf("providers = %v, want [github]", providers)
}
authorizeURL, err := service.OAuthAuthorizeURL("github", "/app/invite/abc")
if err != nil {
t.Fatalf("authorize url: %v", err)
}
if !strings.HasPrefix(authorizeURL, "http") || !strings.Contains(authorizeURL, "client_id=client-id") {
t.Fatalf("unexpected authorize url: %s", authorizeURL)
}
parsed, err := url.Parse(authorizeURL)
if err != nil {
t.Fatalf("parse authorize url: %v", err)
}
state := parsed.Query().Get("state")
if state == "" || !strings.Contains(state, ".") {
t.Fatalf("state is not signed: %q", state)
}
redirect, err := service.verifyOAuthState("github", state)
if err != nil {
t.Fatalf("verify state: %v", err)
}
if redirect != "/app/invite/abc" {
t.Fatalf("redirect = %q", redirect)
}
// Подмена state и чужой провайдер отклоняются.
if _, err := service.verifyOAuthState("google", state); !errors.Is(err, ErrOAuthState) {
t.Fatalf("state for another provider must fail, got %v", err)
}
if _, err := service.verifyOAuthState("github", state+"x"); !errors.Is(err, ErrOAuthState) {
t.Fatalf("tampered state must fail, got %v", err)
}
}
// TestOAuthCallbackCreatesAndLinksAccounts — вход новым аккаунтом, привязка к
// существующему по email, повторный вход и запрет для забаненного инстанса.
func TestOAuthCallbackCreatesAndLinksAccounts(t *testing.T) {
oauthTestServer(t, oauthProfile{Subject: "42", Email: "OAuth@Example.com", EmailVerified: true, Login: "Octo Cat"})
service, st := oauthTestService(t, oauthTestConfig())
ctx := context.Background()
state, err := service.signOAuthState("github", "/app")
if err != nil {
t.Fatalf("sign state: %v", err)
}
user, token, session, redirect, err := service.OAuthCallback(ctx, "github", "good-code", state, url.Values{}, "127.0.0.1", "test-agent")
if err != nil {
t.Fatalf("oauth callback: %v", err)
}
if redirect != "/app" || token == "" || session == nil {
t.Fatalf("unexpected callback result: redirect=%q token=%q", redirect, token)
}
if user.Username != "octo_cat" {
t.Fatalf("username = %q, want octo_cat", user.Username)
}
// Email зашифрован, наружу не отдаётся, но расшифровывается сервисом.
email, err := service.Email(ctx, user.ID)
if err != nil || email != "oauth@example.com" {
t.Fatalf("email = %q, err = %v", email, err)
}
accounts, err := st.ListOAuthAccounts(ctx, user.ID)
if err != nil || len(accounts) != 1 {
t.Fatalf("oauth accounts = %v, err = %v", accounts, err)
}
if accounts[0].SubjectIndex == "" || strings.Contains(accounts[0].SubjectIndex, "42") {
t.Fatalf("subject must be stored as blind index: %q", accounts[0].SubjectIndex)
}
// Повторный вход тем же аккаунтом: новой записи не появляется.
state, err = service.signOAuthState("github", "/app")
if err != nil {
t.Fatalf("sign state: %v", err)
}
again, _, _, _, err := service.OAuthCallback(ctx, "github", "good-code", state, url.Values{}, "127.0.0.1", "test-agent")
if err != nil {
t.Fatalf("second oauth callback: %v", err)
}
if again.ID != user.ID {
t.Fatalf("second login created another account: %d != %d", again.ID, user.ID)
}
// Бан инстанса запрещает вход через провайдера.
if err := st.BanInstanceUser(ctx, user.ID, user.ID, "тест"); err != nil {
t.Fatalf("ban user: %v", err)
}
state, err = service.signOAuthState("github", "/app")
if err != nil {
t.Fatalf("sign state: %v", err)
}
if _, _, _, _, err := service.OAuthCallback(ctx, "github", "good-code", state, url.Values{}, "127.0.0.1", "test-agent"); !errors.Is(err, ErrUserBanned) {
t.Fatalf("banned oauth login: err = %v, want ErrUserBanned", err)
}
}
// TestOAuthCallbackLinksExistingAccountByEmail — провайдер привязывается к
// уже существующему аккаунту с тем же подтверждённым email (blind index).
func TestOAuthCallbackLinksExistingAccountByEmail(t *testing.T) {
oauthTestServer(t, oauthProfile{Subject: "777", Email: "existing@example.com", EmailVerified: true, Login: "existing"})
service, st := oauthTestService(t, oauthTestConfig())
ctx := context.Background()
if _, _, _, err := service.Register(ctx, RegisterInput{
Username: "existing", Email: "existing@example.com", Password: "correct-horse-battery",
}); err != nil {
t.Fatalf("register: %v", err)
}
existing, err := service.UserByEmail(ctx, "existing@example.com")
if err != nil {
t.Fatalf("find user: %v", err)
}
state, err := service.signOAuthState("github", "/app")
if err != nil {
t.Fatalf("sign state: %v", err)
}
user, _, _, _, err := service.OAuthCallback(ctx, "github", "good-code", state, url.Values{}, "127.0.0.1", "agent")
if err != nil {
t.Fatalf("oauth callback: %v", err)
}
if user.ID != existing.ID {
t.Fatalf("oauth login must reuse the account: %d != %d", user.ID, existing.ID)
}
accounts, err := st.ListOAuthAccounts(ctx, user.ID)
if err != nil || len(accounts) != 1 {
t.Fatalf("link was not created: %v, err = %v", accounts, err)
}
events, err := st.ListSecurityEvents(ctx, user.ID, 20)
if err != nil {
t.Fatalf("list security events: %v", err)
}
found := false
for _, event := range events {
if event.Type == "oauth_linked" {
found = true
}
}
if !found {
t.Fatal("security events have no oauth_linked")
}
}
// TestOAuthCallbackRejectsUnverifiedEmail — неподтверждённый email не даёт
// войти: иначе чужой адрес в профиле провайдера уводил бы в чужой аккаунт.
func TestOAuthCallbackRejectsUnverifiedEmail(t *testing.T) {
oauthTestServer(t, oauthProfile{Subject: "5", Email: "unverified@example.com", EmailVerified: false})
service, _ := oauthTestService(t, oauthTestConfig())
state, err := service.signOAuthState("github", "/app")
if err != nil {
t.Fatalf("sign state: %v", err)
}
if _, _, _, _, err := service.OAuthCallback(context.Background(), "github", "good-code", state, url.Values{}, "127.0.0.1", "agent"); !errors.Is(err, ErrOAuthEmailUnverified) {
t.Fatalf("unverified email: err = %v, want ErrOAuthEmailUnverified", err)
}
}
// TestOAuthCallbackStateAndCodeErrors — неверный state и код отклоняются.
func TestOAuthCallbackStateAndCodeErrors(t *testing.T) {
oauthTestServer(t, oauthProfile{Subject: "6", Email: "user@example.com", EmailVerified: true})
service, _ := oauthTestService(t, oauthTestConfig())
ctx := context.Background()
if _, _, _, _, err := service.OAuthCallback(ctx, "github", "good-code", "broken", url.Values{}, "127.0.0.1", "agent"); !errors.Is(err, ErrOAuthState) {
t.Fatalf("bad state: err = %v, want ErrOAuthState", err)
}
state, err := service.signOAuthState("github", "/app")
if err != nil {
t.Fatalf("sign state: %v", err)
}
if _, _, _, _, err := service.OAuthCallback(ctx, "github", "", state, url.Values{}, "127.0.0.1", "agent"); !errors.Is(err, ErrOAuthExchange) {
t.Fatalf("empty code: err = %v, want ErrOAuthExchange", err)
}
if _, _, _, _, err := service.OAuthCallback(ctx, "github", "bad-code", state, url.Values{}, "127.0.0.1", "agent"); !errors.Is(err, ErrOAuthExchange) {
t.Fatalf("bad code: err = %v, want ErrOAuthExchange", err)
}
}
// TestOAuthRegistrationDisabledBlocksNewAccounts — существующие аккаунты
// входят, новые не создаются при выключенной регистрации (7.1).
func TestOAuthRegistrationDisabledBlocksNewAccounts(t *testing.T) {
oauthTestServer(t, oauthProfile{Subject: "8", Email: "new@example.com", EmailVerified: true, Login: "new"})
service, st := oauthTestService(t, oauthTestConfig())
ctx := context.Background()
if err := st.SetInstanceSetting(ctx, "registration_enabled", "false"); err != nil {
t.Fatalf("disable registration: %v", err)
}
state, err := service.signOAuthState("github", "/app")
if err != nil {
t.Fatalf("sign state: %v", err)
}
if _, _, _, _, err := service.OAuthCallback(ctx, "github", "good-code", state, url.Values{}, "127.0.0.1", "agent"); !errors.Is(err, ErrRegistrationOff) {
t.Fatalf("registration disabled: err = %v, want ErrRegistrationOff", err)
}
}
// TestOAuthStateExpires — state старше окна не принимается, даже если подпись
// верна: иначе ссылку можно было бы использовать неограниченно долго.
func TestOAuthStateExpires(t *testing.T) {
service, _ := oauthTestService(t, oauthTestConfig())
payload, err := json.Marshal(map[string]string{
"provider": "github",
"redirect": "/app",
"issued": strconv.FormatInt(time.Now().UTC().Add(-time.Hour).Unix(), 10),
"nonce": "deadbeefdeadbeef",
})
if err != nil {
t.Fatalf("marshal payload: %v", err)
}
encoded := base64.RawURLEncoding.EncodeToString(payload)
expired := encoded + "." + service.signStatePayload(encoded)
if _, err := service.verifyOAuthState("github", expired); !errors.Is(err, ErrOAuthState) {
t.Fatalf("expired state: err = %v, want ErrOAuthState", err)
}
}
// TestPasskeyHandleStableAndOpaque — user handle не раскрывает id аккаунта и
// стабилен между вызовами.
func TestPasskeyHandleStableAndOpaque(t *testing.T) {
service, _ := oauthTestService(t, oauthTestBaseConfig())
first := service.passkeyHandle(12345)
second := service.passkeyHandle(12345)
if len(first) != 16 || !bytes.Equal(first, second) {
t.Fatalf("handle must be 16 stable bytes: %v / %v", first, second)
}
if strings.Contains(string(first), "12345") {
t.Fatal("handle must not contain the account id")
}
if bytes.Equal(service.passkeyHandle(12346), first) {
t.Fatal("handles of different accounts must differ")
}
}
// TestPasskeyCeremonyStoreIsOneTime — церемония выдаётся один раз и по TTL.
func TestPasskeyCeremonyStoreIsOneTime(t *testing.T) {
store := newCeremonyStore()
now := time.Now().UTC()
store.now = func() time.Time { return now }
store.put("token", ceremony{userID: 1, expiresAt: now.Add(time.Minute)})
if _, ok := store.take("token"); !ok {
t.Fatal("ceremony must be available once")
}
if _, ok := store.take("token"); ok {
t.Fatal("ceremony must not be reusable")
}
store.put("expired", ceremony{userID: 1, expiresAt: now.Add(-time.Second)})
if _, ok := store.take("expired"); ok {
t.Fatal("expired ceremony must not be usable")
}
}
// TestSanitizeUsername — логин провайдера приводится к правилам glchat.
func TestSanitizeUsername(t *testing.T) {
cases := map[string]string{
"Octo-Cat": "octo_cat",
" John Doe ": "john_doe",
"ю": "",
"a": "",
"user.name": "user.name",
"UPPER": "upper",
}
for input, expected := range cases {
if got := sanitizeUsername(input); got != expected {
t.Errorf("sanitizeUsername(%q) = %q, want %q", input, got, expected)
}
}
}
// TestSafeRedirect — открытый редирект невозможен.
func TestSafeRedirect(t *testing.T) {
if safeRedirect("//evil.example") != "/app" || safeRedirect("https://evil.example") != "/app" {
t.Fatal("external redirects must be rejected")
}
if safeRedirect("/app/invite/x") != "/app/invite/x" || safeRedirect("") != "/app" {
t.Fatal("internal redirects must be preserved")
}
}
// проверяем, что ключ blind index провайдера не совпадает с email-индексом.
func TestOAuthSubjectIndexDiffersFromEmail(t *testing.T) {
key, err := crypto.ParseMasterKey(oauthTestConfig().MasterKey)
if err != nil {
t.Fatalf("parse master key: %v", err)
}
subject := key.BlindIndex("github:42")
email := key.BlindIndex("user@example.com")
if subject == email || len(subject) != len(email) {
t.Fatal("blind indexes must be independent")
}
}
// TestOAuthVKIDFlow — вход через VK ID: PKCE (code_challenge в запросе
// авторизации и code_verifier при обмене), device_id из callback и сервисный
// ключ отдельным параметром service_token (VK ID не ждёт client_secret),
// профиль забирается методом /oauth2/user_info с client_id в теле.
func TestOAuthVKIDFlow(t *testing.T) {
mux := http.NewServeMux()
var tokenForm url.Values
mux.HandleFunc("/token", func(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
w.WriteHeader(http.StatusBadRequest)
return
}
tokenForm = r.PostForm
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"access_token": "vk-access-token",
"token_type": "Bearer",
"expires_in": 3600,
"user_id": 42,
})
})
var infoForm url.Values
mux.HandleFunc("/user_info", func(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
w.WriteHeader(http.StatusBadRequest)
return
}
infoForm = r.PostForm
_ = json.NewEncoder(w).Encode(map[string]any{
"user": map[string]any{
"user_id": 42,
"first_name": "Иван",
"last_name": "Петров",
"email": "ivan@example.com",
},
})
})
server := httptest.NewServer(mux)
t.Cleanup(server.Close)
originalProviders := oauthProviderSet
originalInfoURL := vkUserInfoURL
providers := defaultOAuthProviders()
vk := providers["vk"]
vk.Endpoint.AuthURL = server.URL + "/authorize"
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-client"
cfg.OAuthVKClientSecret = "vk-service-token"
service, _ := oauthTestService(t, cfg)
if providers := service.OAuthProviders(); len(providers) != 2 || providers[0].ID != "vk" {
t.Fatalf("providers = %v, want vk первым", providers)
}
authorizeURL, err := service.OAuthAuthorizeURL("vk", "/app/friends")
if err != nil {
t.Fatalf("authorize url: %v", err)
}
parsed, err := url.Parse(authorizeURL)
if err != nil {
t.Fatalf("parse authorize url: %v", err)
}
query := parsed.Query()
if query.Get("code_challenge") == "" || query.Get("code_challenge_method") != "S256" {
t.Fatalf("в ссылке нет PKCE: %s", authorizeURL)
}
if query.Get("client_id") != "vk-client" {
t.Fatalf("client_id = %q", query.Get("client_id"))
}
if !strings.Contains(query.Get("scope"), "email") {
t.Fatalf("scope = %q, want email", query.Get("scope"))
}
state := query.Get("state")
if state == "" {
t.Fatal("в ссылке нет state")
}
ctx := context.Background()
user, token, session, redirect, err := service.OAuthCallback(ctx, "vk",
"vk-code", state, url.Values{"device_id": {"device-123"}}, "127.0.0.1", "agent")
if err != nil {
t.Fatalf("OAuthCallback: %v", err)
}
if token == "" || session == nil {
t.Fatalf("token=%q session=%v", token, session)
}
// Email зашифрован: наружу его отдаёт только сервис.
email, err := service.Email(ctx, user.ID)
if err != nil || email != "ivan@example.com" {
t.Fatalf("email = %q, err = %v", email, err)
}
if redirect != "/app/friends" {
t.Fatalf("redirect = %q", redirect)
}
// Обмен кода: PKCE-верификатор (43 символа), device_id и service_token.
if len(tokenForm.Get("code_verifier")) != 43 {
t.Fatalf("code_verifier = %q (len %d), want 43", tokenForm.Get("code_verifier"), len(tokenForm.Get("code_verifier")))
}
if tokenForm.Get("device_id") != "device-123" {
t.Fatalf("device_id = %q", tokenForm.Get("device_id"))
}
if tokenForm.Get("service_token") != "vk-service-token" {
t.Fatalf("service_token = %q", tokenForm.Get("service_token"))
}
if tokenForm.Get("client_secret") != "" {
t.Fatalf("client_secret не должен уходить в VK: %q", tokenForm.Get("client_secret"))
}
if tokenForm.Get("state") != state {
t.Fatalf("state в обмене = %q, want %q", tokenForm.Get("state"), state)
}
// Профиль: POST с client_id и токеном.
if infoForm.Get("client_id") != "vk-client" || infoForm.Get("access_token") != "vk-access-token" {
t.Fatalf("user_info form = %v", infoForm)
}
}
// TestOAuthYandexFlow — вход через Яндекс ID: обмен кода с client_id/secret в
// теле запроса и профиль по заголовку `Authorization: OAuth <токен>`.
func TestOAuthYandexFlow(t *testing.T) {
mux := http.NewServeMux()
var infoAuth string
mux.HandleFunc("/token", func(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
w.WriteHeader(http.StatusBadRequest)
return
}
if r.PostForm.Get("client_id") != "ya-client" || r.PostForm.Get("client_secret") != "ya-secret" {
w.WriteHeader(http.StatusUnauthorized)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"access_token": "ya-access-token",
"token_type": "bearer",
"expires_in": 3600,
})
})
mux.HandleFunc("/info", func(w http.ResponseWriter, r *http.Request) {
infoAuth = r.Header.Get("Authorization")
_ = json.NewEncoder(w).Encode(map[string]any{
"id": "1000034426",
"login": "ivan",
"default_email": "ivan@yandex.ru",
"emails": []string{"ivan@yandex.ru"},
"real_name": "Иван Петров",
})
})
server := httptest.NewServer(mux)
t.Cleanup(server.Close)
originalProviders := oauthProviderSet
originalInfoURL := yandexInfoURL
providers := defaultOAuthProviders()
yandex := providers["yandex"]
yandex.Endpoint.AuthURL = server.URL + "/authorize"
yandex.Endpoint.TokenURL = server.URL + "/token"
providers["yandex"] = yandex
oauthProviderSet = providers
yandexInfoURL = server.URL + "/info"
t.Cleanup(func() {
oauthProviderSet = originalProviders
yandexInfoURL = originalInfoURL
})
cfg := oauthTestConfig()
cfg.OAuthYandexClientID = "ya-client"
cfg.OAuthYandexClientSecret = "ya-secret"
service, _ := oauthTestService(t, cfg)
authorizeURL, err := service.OAuthAuthorizeURL("yandex", "/app")
if err != nil {
t.Fatalf("authorize url: %v", err)
}
parsed, err := url.Parse(authorizeURL)
if err != nil {
t.Fatalf("parse authorize url: %v", err)
}
if parsed.Query().Get("code_challenge") != "" {
t.Fatal("Яндекс не требует PKCE: code_challenge не нужен")
}
state := parsed.Query().Get("state")
ctx := context.Background()
user, _, _, _, err := service.OAuthCallback(ctx, "yandex",
"ya-code", state, url.Values{}, "127.0.0.1", "agent")
if err != nil {
t.Fatalf("OAuthCallback: %v", err)
}
email, err := service.Email(ctx, user.ID)
if err != nil || email != "ivan@yandex.ru" {
t.Fatalf("email = %q, err = %v", email, err)
}
if user.Username != "ivan" {
t.Fatalf("username = %q, want логин Яндекса", user.Username)
}
if infoAuth != "OAuth ya-access-token" {
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)
}
}