diff --git a/.markdownlint.yml b/.markdownlint.yml index 436de161..07eb66b6 100644 --- a/.markdownlint.yml +++ b/.markdownlint.yml @@ -1,2 +1,3 @@ --- no-hard-tabs: false +no-duplicate-heading: false diff --git a/README.md b/README.md index d2062405..2ee76530 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,7 @@ Version API 5.131. - Ability to modify HTTP client - Request Limiter - Token pool + - [OAuth](https://pkg.go.dev/github.com/SevereCloud/vksdk/v2/api/oauth) - [Callback API](https://pkg.go.dev/github.com/SevereCloud/vksdk/v2/callback) - Tracking tool for users activity in your VK communities - Supports all events diff --git a/api/oauth/README.md b/api/oauth/README.md new file mode 100644 index 00000000..1541396f --- /dev/null +++ b/api/oauth/README.md @@ -0,0 +1,430 @@ +# Получение ключа доступа + +[![PkgGoDev][doc-badge]][doc] +[![VK][dev-badge]](https://vk.com/dev/access_token) + +Для работы со всеми методами API Вам необходимо передавать в запросе +**access_token** — специальный ключ доступа. Он представляет собой строку из +латинских букв и цифр и может соответствовать отдельному пользователю, +сообществу или самому Вашему приложению. + +ВКонтакте поддерживает несколько способов получения ключа доступа по OAuth 2.0: + +1. **Implicit flow** — требует встраивания браузера в ваше приложение. +Ключ возвращается на устройство пользователя, где был открыт диалог авторизации +(в виде дополнительного параметра URL). Такой ключ может быть использован +только для запросов непосредственно с устройства пользователя. + +2. **Authorization code flow** — двухэтапный вариант с дополнительной +аутентификацией Вашего сервера. Ключ доступа возвращается непосредственно на +сервер и может быть использован, например, для автоматизированных запросов. + +3. **Direct Authorization** — прямая авторизация используя логин и пароль. + +## Права доступа приложения + +[![VK][dev-badge]](https://vk.com/dev/permissions) + +Права доступа определяют возможность использования токена для работы с тем или +иным разделом данных. Так, например, для отправки личного сообщения от имени +пользователя токен должен быть получен с правами `oauth.ScopeUserMessage`. + +Обратите внимание, что некоторые права доступа пользователя из списка +(например, messages) могут быть запрошены только Standalone-приложением — что +означает необходимость использования Implicit Flow для запроса таких прав. + +Если Вы хотите получить права на **Доступ к друзьям** и +**Доступ к статусам пользователя**, то Ваша битовая маска будет равна: + +```go +scope := oauth.ScopeUserFriends + oauth.ScopeUserStatus // 1026 +``` + +С помощью метода +[account.getAppPermissions](https://vk.com/dev/account.getAppPermissions), +можно получить битовую маску настроек текущего пользователя в данном приложении. + +Если, имея битовую маску 1026, Вы хотите проверить, имеет ли она доступ к +друзьям — Вы можете сделать 1026 & 2. + +```go +scope, err := vk.AccountGetAppPermissions(nil) +if err != nil { + log.Fatal(err) +} + +if oauth.CheckScope(scope, oauth.ScopeUserFriends, oauth.ScopeUserStatus) { + log.Println("Имеется доступ к друзьям и статусу") +} +``` + +## Ключ доступа пользователя + +### Authorization code flow + +[![VK][dev-badge]](https://vk.com/dev/authcode_flow_user) + +Используйте Authorization Code Flow для вызова методов API ВКонтакте с +серверной части Вашего приложения. Ключ доступа, полученный таким способом, не +привязан к IP-адресу, но набор прав, которые может получить приложение, +ограничен из соображений безопасности. + +#### Сгенерируйте адрес + +```go +acf := NewAuthCodeFlowUser(oauth.UserParams{ + ClientID: 123456, + RedirectURI: "https://example.com/callback", + Scope: oauth.ScopeUserPhotos + oauth.ScopeUserDocs, +}, clientSecret) +u := acf.URL().String() +``` + +Необходимо перенаправить браузер пользователя по сгенерированному адресу. + +Если пользователь не вошел на сайт, то в диалоговом окне ему будет предложено +ввести свой логин и пароль. + +#### Разрешение прав доступа + +После успешного входа на сайт пользователю будет предложено авторизовать +приложение, разрешив доступ к необходимым настройкам, запрошенным при помощи +параметра scope. + +#### Получение access_token + +После успешной авторизации приложения браузер пользователя будет перенаправлен +по адресу redirect_uri, указанному при открытии диалога авторизации. При этом +код для получения ключа доступа `code` будет передан как GET-параметр. + +```go +func callback(w http.ResponseWriter, req *http.Request) { + t, err := acf.Token(req.URL) + if err != nil { + fmt.Printf("%#v\n", err) + } + + fmt.Printf( + "Токен %s для id%d действует %d секунд", + t.AccessToken, + t.UserID, + t.ExpiresIn, + ) +} +``` + +`acf.Token(req.URL)` выполнит запрос с вашего сервера, чтобы получить ключ +доступа. + +### Implicit flow + +[![VK][dev-badge]](https://vk.com/dev/implicit_flow_user) + +Используйте Implicit Flow для вызова методов API ВКонтакте непосредственно с +устройства пользователя. + +#### Сгенерируйте адрес + +```go +u := oauth.ImplicitFlowUser(oauth.UserParams{ + ClientID: 123456, + Scope: oauth.ScopeUserPhotos + oauth.ScopeUserDocs, +}) +``` + +RedirectURI по умолчанию `https://oauth.vk.com/blank.html` + +#### Разрешение прав доступа + +Необходимо перенаправить **встроенный браузер** по адресу сгенерированному на +предыдущем шаге. + +Рекомендуется проверять, что страница вернула код 200. Если код отличается, а +`content-type` содержит JSON, необходимо распарсить этот JSON в ошибку: + +```go +var errOAuth oauth.Error +err = json.Unmarshal(data, &errOAuth) +``` + +Если пользователь не авторизован ВКонтакте в используемом браузере, то в +диалоговом окне ему будет предложено ввести свой логин и пароль. + +После успешного входа на сайт пользователю будет предложено авторизовать +приложение, разрешив доступ к необходимым настройкам, запрошенным при помощи +параметра scope. + +#### Получение access_token + +После успешной авторизации приложения браузер пользователя будет перенаправлен +по адресу redirect_uri, указанному при открытии диалога авторизации. При этом +ключ доступа к API access_token и другие параметры будут переданы в +URL-фрагменте ссылки. + +```go +t, err := oauth.NewUserTokenFromURL(u) +if err != nil { + fmt.Printf("%#v\n", err) +} + +fmt.Printf( + "Токен %s для id%d действует %d секунд", + t.AccessToken, + t.UserID, + t.ExpiresIn, +) +``` + +`ExpiresIn` содержит 0, если токен бессрочный +(при использовании scope=offline). + +### Direct Authorization + +[![VK][dev-badge]](https://vk.com/dev/auth_direct) + +> **Внимание! Доступ к этому типу авторизации может быть получен только после** +> **предварительного согласования с администрацией ВКонтакте.** +> +> Для подачи заявки на получение доступа Вам необходимо обратиться в службу +> поддержки, указав ID Вашего приложения. +> +> В настоящий момент эта возможность предоставляется только для платформ, не +> поддерживающих стандартную авторизацию. В заявке необходимо кратко описать +> функционал приложения. + +Доверенные приложения могут получить неограниченный по времени access_token для +доступа к API, передав логин и пароль пользователя. + +Обратите внимание, что приложение не должно хранить пароль пользователя. +Выдаваемый access_token не привязан к IP-адресу пользователя, поэтому его +достаточно для последующей работы с API без повторения процедуры авторизации. + +```go +func example(params oauth.DirectAuthParams) (*oauth.UserToken, error) { + t, err := oauth.DirectAuth(params) + if err == nil { + return t, nil + } + + var e *oauth.Error + if !errors.As(err, &e) { + return nil, err + } + + switch e.Type { + case oauth.ErrNeedCaptcha: + message := "Требуется ввести код с картинки:\n" + message += e.CaptchaImg + + _, _ = fmt.Println(message) + + // ... + + params.CaptchaKey = "" + params.CaptchaSID = e.CaptchaSID + + return example(params) + case oauth.ErrNeedValidation: + message := "Введите код" + + switch e.ValidationType { + case oauth.ValidationSMS: + message += ", который пришел в сообщении на номер " + e.PhoneMask + case oauth.ValidationApp: + message += " из приложения для генерации кодов" + } + + fmt.Println(message) + + // ... + + params.Code = "" + + return example(params) + } + + return nil, err +} + + +params := DirectAuthParams{ + ClientSecret: "secret", + ClientID: 123456, + Scope: oauth.ScopeUserPhotos + oauth.ScopeUserDocs, +} + +params.Username = "username" +params.Password = "password" + +t, err := example(params) +if err != nil { + fmt.Printf("%#v\n", err) +} + + +fmt.Printf( + "Токен %s для id%d действует %d секунд", + t.AccessToken, + t.UserID, + t.ExpiresIn, +) +``` + +## Ключ доступа сообщества + +### Получение списка администрируемых сообществ + +Получить ключ доступа сообщества через OAuth может только его администратор. +Чтобы получить ключи доступа сразу для всех или нескольких сообществ +пользователя, мы рекомендуем добавить этот дополнительный шаг в процесс +авторизации. + +Получите ключ доступа пользователя с правами `scope=groups` и сделайте запрос к +методу [groups.get](https://vk.com/dev/groups.get) с параметром `filter=admin`, +чтобы получить список идентификаторов администрируемых сообществ. + +Затем используйте все полученные значения или их часть в качестве параметра +`GroupIDs`. + +### Authorization code flow + +[![VK][dev-badge]](https://vk.com/dev/authcode_flow_group) + +Используйте Authorization Code Flow для вызова методов API ВКонтакте с серверной +части Вашего приложения. Ключ доступа, полученный таким способом, не привязан к +IP-адресу. + +#### Сгенерируйте адрес + +```go +acf := NewAuthCodeFlowGroup(oauth.GroupParams{ + ClientID: 123456, + GroupIDs: []int{1234}, + RedirectURI: "https://example.com/callback", + Scope: oauth.ScopeGroupPhotos + oauth.ScopeGroupDocs, +}, clientSecret) +u := acf.URL().String() +``` + +Необходимо перенаправить браузер пользователя по сгенерированному адресу. + +Если пользователь не вошел на сайт, то в диалоговом окне ему будет предложено +ввести свой логин и пароль. + +#### Получение access_token + +После успешной авторизации приложения браузер пользователя будет перенаправлен +по адресу redirect_uri, указанному при открытии диалога авторизации. При этом +код для получения ключа доступа `code` будет передан как GET-параметр. + +```go +func callback(w http.ResponseWriter, req *http.Request) { + t, err := acf.Token(req.URL) + if err != nil { + fmt.Printf("%#v\n", err) + } + + for _, groupToken := range t.Groups { + fmt.Printf( + "Токен %s для club%d действует %d секунд", + groupToken.AccessToken, + groupToken.GroupID, + t.ExpiresIn, + ) + } +} +``` + +`acf.Token(req.URL)` выполнит запрос с вашего сервера, чтобы получить ключ +доступа. + +### Implicit flow + +[![VK][dev-badge]](https://vk.com/dev/implicit_flow_group) + +Используйте Implicit Flow для вызова методов API ВКонтакте непосредственно с +устройства пользователя. + +#### Сгенерируйте адрес + +Перед открытием диалога авторизации рекомендуется убедиться, что пользователь +является администратором сообщества, для которого необходимо получить ключ +доступа + +```go +u := oauth.ImplicitFlowGroup(oauth.GroupParams{ + ClientID: 123456, + GroupIDs: []int{1234}, + Scope: oauth.ScopeGroupPhotos + oauth.ScopeGroupDocs, +}) +``` + +RedirectURI по умолчанию `https://oauth.vk.com/blank.html` + +#### Разрешение прав доступа + +Необходимо перенаправить **встроенный браузер** по адресу сгенерированному на +предыдущем шаге. + +Рекомендуется проверять, что страница вернула код 200. Если код отличается, а +`content-type` содержит JSON, необходимо распарсить этот JSON в ошибку: + +```go +var errOAuth oauth.Error +err = json.Unmarshal(data, &errOAuth) +``` + +После успешного входа на сайт пользователю будет предложено авторизовать +приложение, разрешив доступ к необходимым настройкам, запрошенным при помощи +параметра scope. + +#### Получение access_token + +После успешной авторизации приложения браузер пользователя будет перенаправлен +по адресу redirect_uri, указанному при открытии диалога авторизации. При этом +ключ доступа к API access_token и другие параметры будут переданы в +URL-фрагменте ссылки. + +```go +t, err := oauth.NewGroupTokensFromURL(u) +if err != nil { + fmt.Printf("%#v\n", err) +} + +for _, groupToken := range t.Groups { + fmt.Printf( + "Токен %s для club%d действует %d секунд", + groupToken.AccessToken, + groupToken.GroupID, + t.ExpiresIn, + ) +} +``` + +## Сервисный ключ доступа + +Сервисный ключ нужен для запросов, которые не требуют авторизации пользователя +или сообщества. Это такие методы, как +[secure.sendNotification][secure.sendNotification] для отправки уведомлений от +приложения, или [secure.addAppEvent][secure.addAppEvent] для добавления +информации о достижениях, а также, начиная с апреля 2017 года, открытые методы, +например, [users.get][users.get]. + +Получить сервисный ключ доступа можно в [настройках][appsmanage] Вашего +приложения. Ключ не привязан к IP-адресу при использовании с открытыми +методами, срок его действия не ограничен. Если ключ был скомпрометирован, Вы +можете сгенерировать новый ключ, при этом старый будет аннулирован. + +Сервисный ключ доступа идентифицирует Ваше приложение. Все запросы к API, +совершённые с использованием Вашего ключа доступа, будут считаться совершёнными +от имени Вашего приложения. Сервисный ключ доступа можно использовать только +для запросов с серверной стороны приложения, его нельзя передавать и хранить +на клиенте. + +[doc-badge]: https://pkg.go.dev/badge/github.com/SevereCloud/vksdk/api/oauth +[doc]: https://pkg.go.dev/github.com/SevereCloud/vksdk/api/oauth +[dev-badge]: https://img.shields.io/badge/developers-%234a76a8.svg?logo=VK&logoColor=white +[users.get]: https://vk.com/dev/users.get +[secure.addAppEvent]: https://vk.com/dev/secure.addAppEvent +[secure.sendNotification]: https://vk.com/dev/secure.sendNotification +[appsmanage]: https://vk.com/apps?act=manage diff --git a/api/oauth/errors.go b/api/oauth/errors.go new file mode 100644 index 00000000..954a7d5b --- /dev/null +++ b/api/oauth/errors.go @@ -0,0 +1,96 @@ +// Package oauth ... +package oauth // import "github.com/SevereCloud/vksdk/v2/api/oauth" + +import "errors" + +// ValidationType ... +type ValidationType string + +// Possible values. +const ( + ValidationSMS ValidationType = "2fa_sms" + ValidationApp ValidationType = "2fa_app" +) + +// ErrorType for oauth. +type ErrorType string + +// Error types. +// +// See https://tools.ietf.org/html/rfc6749#section-4.2.2.1 +const ( + ErrInvalidRequest ErrorType = "invalid_request" + ErrUnauthorizedClient ErrorType = "unauthorized_client" + ErrUnsupportedResponseType ErrorType = "unsupported_response_type" + ErrInvalidScope ErrorType = "invalid_scope" + ErrServerError ErrorType = "server_error" + ErrTemporarilyUnavailable ErrorType = "temporarily_unavailable" + ErrAccessDenied ErrorType = "access_denied" + + ErrInvalidGrant ErrorType = "invalid_grant" + + ErrNeedValidation ErrorType = "need_validation" + ErrNeedCaptcha ErrorType = "need_captcha" +) + +// Error returns the message of a Error. +func (e ErrorType) Error() string { + return "oauth: error with type " + string(e) +} + +// ErrorReason for oauth. +type ErrorReason string + +// Error returns the message of a Error. +func (e ErrorReason) Error() string { + return "oauth: error with reason " + string(e) +} + +// ErrorReason types. +const ( + ErrUserDenied ErrorReason = "user_denied" +) + +// Error for oauth. +type Error struct { + Type ErrorType `json:"error"` + Reason ErrorReason `json:"error_reason,omitempty"` + Description string `json:"error_description,omitempty"` + + // For auth direct + CaptchaSID string `json:"captcha_sid,omitempty"` + CaptchaImg string `json:"captcha_img,omitempty"` + RedirectURI string `json:"redirect_uri,omitempty"` + ValidationType ValidationType `json:"validation_type,omitempty"` + PhoneMask string `json:"phone_mask,omitempty"` +} + +// Error returns the message of a Error. +func (e Error) Error() string { + if e.Description != "" { + return "oauth: " + e.Description + } + + return e.Type.Error() +} + +// Is unwraps its first argument sequentially looking for an error that matches +// the second. +func (e Error) Is(target error) bool { + var tError *Error + if errors.As(target, &tError) { + return e.Type == tError.Type && e.Description == tError.Description + } + + var tErrorType ErrorType + if errors.As(target, &tErrorType) { + return e.Type == tErrorType + } + + var tErrorReason ErrorReason + if errors.As(target, &tErrorReason) { + return e.Reason == tErrorReason + } + + return false +} diff --git a/api/oauth/errors_test.go b/api/oauth/errors_test.go new file mode 100644 index 00000000..b48cdb92 --- /dev/null +++ b/api/oauth/errors_test.go @@ -0,0 +1,76 @@ +package oauth_test + +import ( + "errors" + "testing" + + "github.com/SevereCloud/vksdk/v2/api/oauth" + "github.com/stretchr/testify/assert" +) + +type otherError string + +func (e otherError) Error() string { + return string(e) +} + +func TestErrorType(t *testing.T) { + t.Parallel() + + err := oauth.ErrorType("aoa") + assert.EqualError(t, err, "oauth: error with type aoa") +} + +func TestErrorReason(t *testing.T) { + t.Parallel() + + err := oauth.ErrorReason("aoa") + assert.EqualError(t, err, "oauth: error with reason aoa") +} + +func TestError_Error(t *testing.T) { + t.Parallel() + + err := oauth.Error{ + Type: oauth.ErrorType("aoa"), + Description: "test message", + } + assert.EqualError(t, err, "oauth: test message") + + err = oauth.Error{ + Type: oauth.ErrorType("aoa"), + } + assert.EqualError(t, err, "oauth: error with type aoa") +} + +func TestError_Is(t *testing.T) { + t.Parallel() + + f := func(err *oauth.Error, target error, want bool) { + t.Helper() + + assert.Equal(t, want, errors.Is(err, target)) + } + + f(&oauth.Error{Type: oauth.ErrorType("aoa")}, &oauth.Error{Type: oauth.ErrorType("aoa")}, true) + f(&oauth.Error{Type: oauth.ErrAccessDenied}, oauth.ErrAccessDenied, true) + f(&oauth.Error{Reason: oauth.ErrUserDenied}, oauth.ErrUserDenied, true) + f(&oauth.Error{Type: oauth.ErrorType("aoa"), Description: "123"}, &oauth.Error{Type: oauth.ErrorType("aoa"), Description: "123"}, true) + + f(&oauth.Error{Type: oauth.ErrorType("aoa")}, &oauth.Error{Type: oauth.ErrorType("oao")}, false) + f(&oauth.Error{Type: oauth.ErrorType("aoa")}, oauth.ErrorType("oao"), false) + f(&oauth.Error{Reason: oauth.ErrorReason("aoa")}, oauth.ErrorReason("oao"), false) + f(&oauth.Error{Type: oauth.ErrorType("aoa"), Description: "123"}, &oauth.Error{Type: oauth.ErrorType("aoa"), Description: "321"}, false) + f(&oauth.Error{Type: oauth.ErrorType("aoa")}, otherError("test"), false) +} + +func TestError_As(t *testing.T) { + t.Parallel() + + var target *oauth.Error + + err := &oauth.Error{Type: oauth.ErrorType("aoa")} + if !errors.As(err, &target) && target.Type == "aoa" { + t.Error("As not working") + } +} diff --git a/api/oauth/group.go b/api/oauth/group.go new file mode 100644 index 00000000..b8d56ce1 --- /dev/null +++ b/api/oauth/group.go @@ -0,0 +1,232 @@ +// Package oauth ... +package oauth // import "github.com/SevereCloud/vksdk/v2/api/oauth" + +import ( + "encoding/json" + "io/ioutil" + "net/http" + "net/url" + "strconv" + "strings" + + "github.com/SevereCloud/vksdk/v2/internal" +) + +// GroupToken struct with access token. +type GroupToken struct { + GroupID int `json:"group_id"` + AccessToken string `json:"access_token"` +} + +// GroupTokens struct with access tokens. +type GroupTokens struct { + Groups []GroupToken `json:"groups"` + ExpiresIn int `json:"expires_in"` +} + +// NewGroupTokensFromJSON return group tokens. +func NewGroupTokensFromJSON(data []byte) (*GroupTokens, error) { + var e Error + + err := json.Unmarshal(data, &e) + if err != nil { + return nil, err + } + + if e.Type != "" { + return nil, &e + } + + var t GroupTokens + err = json.Unmarshal(data, &t) + + return &t, err +} + +// NewGroupTokensFromURL return group tokens. +func NewGroupTokensFromURL(u *url.URL) (*GroupTokens, error) { + v, err := url.ParseQuery(u.Fragment) + if err != nil { + return nil, err + } + + if errType := v.Get("error"); errType != "" { + return nil, &Error{ + Type: ErrorType(errType), + Reason: ErrorReason(v.Get("error_reason")), + Description: v.Get("error_description"), + } + } + + t := &GroupTokens{ + Groups: make([]GroupToken, 0), + } + + for key, vs := range v { + // NOTE: vs is not empty + if strings.HasPrefix(key, "access_token_") { + groupID, _ := strconv.Atoi(key[13:]) + accessToken := vs[0] + + t.Groups = append(t.Groups, GroupToken{ + GroupID: groupID, + AccessToken: accessToken, + }) + } + + if key == "expires_in" { + t.ExpiresIn, _ = strconv.Atoi(vs[0]) + } + } + + return t, nil +} + +// GroupParams struct. +type GroupParams struct { + ClientID int // required + RedirectURI string + GroupIDs []int + Display Display // Default mobile + Scope int + V string // Default version module + State string +} + +// Values ... +func (p GroupParams) Values() *url.Values { + q := &url.Values{} + + q.Set("client_id", strconv.Itoa(p.ClientID)) + + if p.RedirectURI == "" { + q.Set("redirect_uri", DefaultRedirectURI) + } else { + q.Set("redirect_uri", p.RedirectURI) + } + + var groupIDs string + + for i, id := range p.GroupIDs { + if i != 0 { + groupIDs += "," + } + + groupIDs += strconv.Itoa(id) + } + + q.Set("group_ids", groupIDs) + q.Set("display", string(p.Display)) + q.Set("scope", strconv.Itoa(p.Scope)) + q.Set("state", p.State) + + if p.V == "" { + q.Set("v", version) + } else { + q.Set("v", p.V) + } + + return q +} + +// ImplicitFlowGroup need to run methods directly from users devices. Access +// token received this way can not be used for server requests. +// +// https://vk.com/dev/implicit_flow_group +func ImplicitFlowGroup(p GroupParams) *url.URL { + q := p.Values() + q.Set("response_type", "token") + + u := &url.URL{ + Scheme: scheme, + Host: OAuthHost, + Path: "authorize", + RawQuery: q.Encode(), + } + + return u +} + +// AuthCodeFlowGroup need to run VK API methods from the server side of an +// application. Access token received this way is not bound to an ip address. +// +// https://vk.com/dev/authcode_flow_group +type AuthCodeFlowGroup struct { + params GroupParams + clientSecret string + Client *http.Client + UserAgent string +} + +// NewAuthCodeFlowGroup returns a new AuthcodeFlowGroup. +func NewAuthCodeFlowGroup(p GroupParams, clientSecret string) *AuthCodeFlowGroup { + return &AuthCodeFlowGroup{ + params: p, + clientSecret: clientSecret, + Client: http.DefaultClient, + UserAgent: internal.UserAgent, + } +} + +// URL authorization dialog. +func (a AuthCodeFlowGroup) URL() *url.URL { + q := a.params.Values() + q.Set("response_type", "code") + + u := &url.URL{ + Scheme: scheme, + Host: OAuthHost, + Path: "authorize", + RawQuery: q.Encode(), + } + + return u +} + +func (a AuthCodeFlowGroup) buildRequest(code string) *http.Request { + q := &url.Values{} + + q.Set("client_id", strconv.Itoa(a.params.ClientID)) + q.Set("client_secret", a.clientSecret) + q.Set("redirect_uri", a.params.RedirectURI) + q.Set("code", code) + + uReq := &url.URL{ + Scheme: scheme, + Host: OAuthHost, + Path: "access_token", + RawQuery: q.Encode(), + } + + req, _ := http.NewRequest("GET", uReq.String(), nil) + req.Header.Set("User-Agent", a.UserAgent) + + return req +} + +func (a AuthCodeFlowGroup) request(code string) (*http.Response, error) { + req := a.buildRequest(code) + + return a.Client.Do(req) +} + +// Token ... +func (a AuthCodeFlowGroup) Token(u *url.URL) (*GroupTokens, error) { + code, err := parseCode(u) + if err != nil { + return nil, err + } + + resp, err := a.request(code) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + data, err := ioutil.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + return NewGroupTokensFromJSON(data) +} diff --git a/api/oauth/group_test.go b/api/oauth/group_test.go new file mode 100644 index 00000000..e35695ac --- /dev/null +++ b/api/oauth/group_test.go @@ -0,0 +1,166 @@ +package oauth_test + +import ( + "errors" + "net/url" + "testing" + + "github.com/SevereCloud/vksdk/v2/api/oauth" + "github.com/stretchr/testify/assert" +) + +func TestNewGroupTokensFromJSON(t *testing.T) { + t.Parallel() + + f := func(data []byte, wantToken *oauth.GroupTokens, wantErr string) { + t.Helper() + + token, err := oauth.NewGroupTokensFromJSON(data) + if err != nil { + assert.EqualError(t, err, wantErr) + } + + assert.Equal(t, token, wantToken) + } + + f([]byte("123"), nil, "json: cannot unmarshal number into Go value of type oauth.Error") + f( + []byte(`{"groups":[{"access_token":"533bacf01e11f55b536a565b57531ac114461ae8736d6506a3","group_id":66748}],"expires_in":43200}`), + &oauth.GroupTokens{ + Groups: []oauth.GroupToken{ + { + GroupID: 66748, + AccessToken: "533bacf01e11f55b536a565b57531ac114461ae8736d6506a3", + }, + }, + ExpiresIn: 43200, + }, + "", + ) + f( + []byte(`{"error":"invalid_grant","error_description":"Code is expired."}`), + nil, + "oauth: Code is expired.", + ) +} + +func TestNewGroupTokensFromURL(t *testing.T) { + t.Parallel() + + f := func(rawurl string, wantToken *oauth.GroupTokens, wantErr string) { + t.Helper() + + u, _ := url.Parse(rawurl) + + token, err := oauth.NewGroupTokensFromURL(u) + if err != nil { + assert.EqualError(t, err, wantErr) + } + + assert.Equal(t, token, wantToken) + } + + f( + "http://REDIRECT_URI#&test&access_token_8492=533bacf01e11f55b536a565b57531ad114461ae8736d6506a3&expires_in=86400", + &oauth.GroupTokens{ + Groups: []oauth.GroupToken{ + { + GroupID: 8492, + AccessToken: "533bacf01e11f55b536a565b57531ad114461ae8736d6506a3", + }, + }, + ExpiresIn: 86400, + }, + "", + ) + f( + "http://REDIRECT_URI#error=access_denied&error_description=The+user+or+authorization+server+denied+the+request.", + nil, + "oauth: The user or authorization server denied the request.", + ) +} + +func TestNewGroupTokensFromURL_Error(t *testing.T) { + t.Parallel() + + _, err := oauth.NewGroupTokensFromURL(&url.URL{ + Fragment: "%gh&%ij", + }) + assert.EqualError(t, err, `invalid URL escape "%gh"`) +} + +func TestAuthCodeFlowGroup_URL(t *testing.T) { + t.Parallel() + + f := func(acf *oauth.AuthCodeFlowGroup, wantURL string) { + t.Helper() + + assert.Equal(t, wantURL, acf.URL().String()) + } + + f(oauth.NewAuthCodeFlowGroup(oauth.GroupParams{ + ClientID: 6888183, + GroupIDs: []int{1234}, + Scope: oauth.ScopeGroupPhotos + oauth.ScopeGroupDocs, + RedirectURI: "https://example.com/callback", + V: "5.100", + }, "clientSecret"), "https://oauth.vk.com/authorize?client_id=6888183&display=&group_ids=1234&redirect_uri=https%3A%2F%2Fexample.com%2Fcallback&response_type=code&scope=131076&state=&v=5.100") + f(oauth.NewAuthCodeFlowGroup(oauth.GroupParams{ + ClientID: 6888183, + GroupIDs: []int{1234, 321}, + Scope: oauth.ScopeGroupPhotos + oauth.ScopeGroupDocs, + }, "clientSecret"), "https://oauth.vk.com/authorize?client_id=6888183&display=&group_ids=1234%2C321&redirect_uri=https%3A%2F%2Foauth.vk.com%2Fblank.html&response_type=code&scope=131076&state=&v=5.131") +} + +func TestAuthCodeFlowGroup_Token(t *testing.T) { + t.Parallel() + + acf := oauth.NewAuthCodeFlowGroup(oauth.GroupParams{ + ClientID: 6888183, + GroupIDs: []int{1234}, + Scope: oauth.ScopeGroupPhotos + oauth.ScopeGroupDocs, + }, "clientSecretGroup") + + u, _ := url.Parse("https://oauth.vk.com/blank.html#code=2fb239386220842e7c") + + _, err := acf.Token(u) + + var e *oauth.Error + if !errors.As(err, &e) { + t.Error(err) + } + + f := func(rawURL, errString string) { + t.Helper() + + u, _ := url.Parse(rawURL) + _, err := acf.Token(u) + assert.EqualError(t, err, errString) + } + + // f("https://oauth.vk.com/blank.html#;", "invalid semicolon separator in query") + f("https://oauth.vk.com/blank.html#error=invalid_request&error_description=Invalid+display+parameter", "oauth: Invalid display parameter") +} + +func TestImplicitFlowGroup(t *testing.T) { + t.Parallel() + + f := func(p oauth.GroupParams, wantURL string) { + t.Helper() + + assert.Equal(t, wantURL, oauth.ImplicitFlowGroup(p).String()) + } + + f(oauth.GroupParams{ + ClientID: 6888183, + GroupIDs: []int{1234}, + Scope: oauth.ScopeGroupPhotos + oauth.ScopeGroupDocs, + RedirectURI: "https://example.com/callback", + V: "5.100", + }, "https://oauth.vk.com/authorize?client_id=6888183&display=&group_ids=1234&redirect_uri=https%3A%2F%2Fexample.com%2Fcallback&response_type=token&scope=131076&state=&v=5.100") + f(oauth.GroupParams{ + ClientID: 6888183, + GroupIDs: []int{1234}, + Scope: oauth.ScopeGroupPhotos + oauth.ScopeGroupDocs, + }, "https://oauth.vk.com/authorize?client_id=6888183&display=&group_ids=1234&redirect_uri=https%3A%2F%2Foauth.vk.com%2Fblank.html&response_type=token&scope=131076&state=&v=5.131") +} diff --git a/api/oauth/oauth.go b/api/oauth/oauth.go new file mode 100644 index 00000000..a09de06c --- /dev/null +++ b/api/oauth/oauth.go @@ -0,0 +1,94 @@ +// Package oauth ... +package oauth // import "github.com/SevereCloud/vksdk/v2/api/oauth" + +import ( + "net/url" + + "github.com/SevereCloud/vksdk/v2/api" +) + +// nolint:gochecknoglobals +var ( + OAuthHost = "oauth.vk.com" + DefaultRedirectURI = "https://oauth.vk.com/blank.html" +) + +const ( + scheme = "https" + version = api.Version +) + +// Access Permissions for User Token. +const ( + ScopeUserNotify = 1 << 0 + ScopeUserFriends = 1 << 1 + ScopeUserPhotos = 1 << 2 + ScopeUserAudio = 1 << 3 + ScopeUserVideo = 1 << 4 + ScopeUserStories = 1 << 6 + ScopeUserPages = 1 << 7 + ScopeUserMenu = 1 << 8 + ScopeUserWallmenu = 1 << 9 + ScopeUserStatus = 1 << 10 + ScopeUserNotes = 1 << 11 + ScopeUserMessages = 1 << 12 + ScopeUserWall = 1 << 13 + ScopeUserAds = 1 << 15 + ScopeUserOffline = 1 << 16 + ScopeUserDocs = 1 << 17 + ScopeUserGroups = 1 << 18 + ScopeUserNotifications = 1 << 19 + ScopeUserStats = 1 << 20 + ScopeUserEmail = 1 << 22 + ScopeUserAdsweb = 1 << 23 + ScopeUserLeads = 1 << 24 + ScopeUserGroupMessages = 1 << 25 + ScopeUserExchange = 1 << 26 + ScopeUserMarket = 1 << 27 + ScopeUserPhone = 1 << 28 +) + +// Access Permissions for Community Token. +const ( + ScopeGroupStories = 1 << 0 + ScopeGroupPhotos = 1 << 2 + ScopeGroupAppWidget = 1 << 6 + ScopeGroupMessages = 1 << 12 + ScopeGroupDocs = 1 << 17 + ScopeGroupManage = 1 << 18 +) + +// CheckScope ... +func CheckScope(scope int, permissions ...int) bool { + for i := 0; i < len(permissions); i++ { + if scope&permissions[i] != permissions[i] { + return false + } + } + + return true +} + +// Display sets authorization page appearance. +type Display string + +// The supported values. +const ( + Page Display = "page" // authorization form in a separate window + Popup Display = "popup" // a pop-up window + Mobile Display = "mobile" // authorization for mobile devices (uses no Javascript). +) + +func parseCode(u *url.URL) (string, error) { + v, err := url.ParseQuery(u.Fragment) + + if errType := v.Get("error"); errType != "" { + err = &Error{ + Type: ErrorType(errType), + Reason: ErrorReason(v.Get("error_reason")), + Description: v.Get("error_description"), + } + } + + return v.Get("code"), err +} diff --git a/api/oauth/oauth_test.go b/api/oauth/oauth_test.go new file mode 100644 index 00000000..ba004a07 --- /dev/null +++ b/api/oauth/oauth_test.go @@ -0,0 +1,36 @@ +package oauth_test + +import ( + "testing" + + "github.com/SevereCloud/vksdk/v2/api/oauth" + "github.com/stretchr/testify/assert" +) + +func TestCheckScope(t *testing.T) { + t.Parallel() + + f := func(expected bool, scope int, permissions ...int) { + t.Helper() + + actual := oauth.CheckScope(scope, permissions...) + + assert.Equal(t, expected, actual) + } + + f( + true, + 1026, + oauth.ScopeUserFriends, oauth.ScopeUserStatus, + ) + f( + true, + 1030, + oauth.ScopeUserFriends, oauth.ScopeUserStatus, + ) + f( + false, + 1026, + oauth.ScopeUserFriends, oauth.ScopeUserStatus, oauth.ScopeUserPhotos, + ) +} diff --git a/api/oauth/user.go b/api/oauth/user.go new file mode 100644 index 00000000..17e44681 --- /dev/null +++ b/api/oauth/user.go @@ -0,0 +1,331 @@ +// Package oauth ... +package oauth // import "github.com/SevereCloud/vksdk/v2/api/oauth" + +import ( + "encoding/json" + "io/ioutil" + "net/http" + "net/url" + "strconv" + + "github.com/SevereCloud/vksdk/v2/internal" +) + +// UserToken ... +type UserToken struct { + AccessToken string `json:"access_token"` + ExpiresIn int `json:"expires_in"` + UserID int `json:"user_id"` + Email string `json:"email,omitempty"` + State string `json:"state,omitempty"` +} + +// NewUserTokenFromJSON ... +func NewUserTokenFromJSON(data []byte) (*UserToken, error) { + var e Error + + err := json.Unmarshal(data, &e) + if err != nil { + return nil, err + } + + if e.Type != "" { + return nil, &e + } + + var t UserToken + err = json.Unmarshal(data, &t) + + return &t, err +} + +// NewUserTokenFromURL ... +func NewUserTokenFromURL(u *url.URL) (*UserToken, error) { + v, err := url.ParseQuery(u.Fragment) + if err != nil { + return nil, err + } + + if errType := v.Get("error"); errType != "" { + return nil, &Error{ + Type: ErrorType(errType), + Reason: ErrorReason(v.Get("error_reason")), + Description: v.Get("error_description"), + } + } + + expiresIn, _ := strconv.Atoi(v.Get("expires_in")) + userID, _ := strconv.Atoi(v.Get("user_id")) + t := &UserToken{ + AccessToken: v.Get("access_token"), + ExpiresIn: expiresIn, + State: v.Get("state"), + UserID: userID, + Email: v.Get("email"), + } + + return t, nil +} + +// UserParams struct. +type UserParams struct { + ClientID int // required + RedirectURI string + Display Display // Default mobile + Scope int + V string // Default version module + State string +} + +// Values ... +func (p UserParams) Values() *url.Values { + q := &url.Values{} + + q.Set("client_id", strconv.Itoa(p.ClientID)) + + if p.RedirectURI == "" { + q.Set("redirect_uri", DefaultRedirectURI) + } else { + q.Set("redirect_uri", p.RedirectURI) + } + + q.Set("display", string(p.Display)) + q.Set("scope", strconv.Itoa(p.Scope)) + q.Set("state", p.State) + + if p.V == "" { + q.Set("v", version) + } else { + q.Set("v", p.V) + } + + return q +} + +// ImplicitFlowUser need to run methods directly from users devices. Access +// token received this way can not be used for server requests. +// +// https://vk.com/dev/implicit_flow_user +func ImplicitFlowUser(p UserParams) *url.URL { + q := p.Values() + q.Set("response_type", "token") + + u := &url.URL{ + Scheme: scheme, + Host: OAuthHost, + Path: "authorize", + RawQuery: q.Encode(), + } + + return u +} + +// AuthCodeFlowUser need to run VK API methods from the server side of an +// application. Access token received this way is not bound to an ip address +// but set of permissions that can be granted is limited for security reasons. +// +// https://vk.com/dev/authcode_flow_user +type AuthCodeFlowUser struct { + params UserParams + clientSecret string + Client *http.Client + UserAgent string +} + +// NewAuthCodeFlowUser returns a new AuthcodeFlowUser. +func NewAuthCodeFlowUser(p UserParams, clientSecret string) *AuthCodeFlowUser { + return &AuthCodeFlowUser{ + params: p, + clientSecret: clientSecret, + Client: http.DefaultClient, + UserAgent: internal.UserAgent, + } +} + +// URL authorization dialog. +func (a AuthCodeFlowUser) URL() *url.URL { + q := a.params.Values() + q.Set("response_type", "code") + + u := &url.URL{ + Scheme: scheme, + Host: OAuthHost, + Path: "authorize", + RawQuery: q.Encode(), + } + + return u +} + +func (a AuthCodeFlowUser) buildRequest(code string) *http.Request { + q := &url.Values{} + + q.Set("client_id", strconv.Itoa(a.params.ClientID)) + q.Set("client_secret", a.clientSecret) + q.Set("redirect_uri", a.params.RedirectURI) + q.Set("code", code) + + uReq := &url.URL{ + Scheme: scheme, + Host: OAuthHost, + Path: "access_token", + RawQuery: q.Encode(), + } + + req, _ := http.NewRequest("GET", uReq.String(), nil) + req.Header.Set("User-Agent", a.UserAgent) + + return req +} + +func (a AuthCodeFlowUser) request(code string) (*http.Response, error) { + req := a.buildRequest(code) + + return a.Client.Do(req) +} + +// Token ... +func (a AuthCodeFlowUser) Token(u *url.URL) (*UserToken, error) { + code, err := parseCode(u) + if err != nil { + return nil, err + } + + resp, err := a.request(code) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + data, err := ioutil.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + return NewUserTokenFromJSON(data) +} + +// DirectAuthParams parameters. +type DirectAuthParams struct { + ClientSecret string + ClientID int + Username, Password string + + Scope int + V string + + TwoFactorSupported bool + ForceSMS bool + Code string + + CaptchaSID string + CaptchaKey string + + TestRedirectURI bool + + Client *http.Client + UserAgent string +} + +func buildDirectAuthRequest(p DirectAuthParams) *http.Request { + q := &url.Values{} + + q.Set("grant_type", "password") + q.Set("client_id", strconv.Itoa(p.ClientID)) + q.Set("client_secret", p.ClientSecret) + q.Set("username", p.Username) + q.Set("password", p.Password) + + q.Set("scope", strconv.Itoa(p.Scope)) + + if p.TestRedirectURI { + q.Set("test_redirect_uri", "1") + } + + if p.V == "" { + p.V = version + } + + q.Set("v", p.V) + + if p.TwoFactorSupported { + q.Set("2fa_supported", "1") + } + + if p.ForceSMS { + q.Set("force_sms", "1") + } + + if p.Code != "" { + q.Set("code", p.Code) + } + + if p.CaptchaSID != "" && p.CaptchaKey != "" { + q.Set("captcha_sid", p.CaptchaSID) + q.Set("captcha_key", p.CaptchaKey) + } + + uReq := &url.URL{ + Scheme: scheme, + Host: OAuthHost, + Path: "token", + RawQuery: q.Encode(), + } + + req, _ := http.NewRequest("GET", uReq.String(), nil) + + if p.UserAgent == "" { + p.UserAgent = internal.UserAgent + } + + req.Header.Set("User-Agent", p.UserAgent) + + return req +} + +// DirectAuth type of authorization. +// +// Please note! This type of authorization is available only after preliminary +// approval of VK administration. +// +// To apply for access you need to contact our support service at +// https://vk.com/support and specify you application ID. +// +// Currently, this functionality is available only for the following categories: +// +// - Fully functional clients. At the time you apply for access your +// application shall be functional (using standard authorization); and you +// shall provide the download link in the request. +// +// - Applications for platforms which do not support standard authorization. +// Provide a short description of application functionality in the request. +// +// Trusted applications can get time-unlimited access_token to access API by +// passing user's login and password. +// +// Note that application shall not store user's password. +// Issued access_token is not bound to user's IP address, that is why it is +// sufficient for using API in the future without repeating authorization +// procedure. +// +// See https://vk.com/dev/auth_direct +func DirectAuth(p DirectAuthParams) (*UserToken, error) { + req := buildDirectAuthRequest(p) + + if p.Client == nil { + p.Client = http.DefaultClient + } + + resp, err := p.Client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + data, err := ioutil.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + return NewUserTokenFromJSON(data) +} diff --git a/api/oauth/user_test.go b/api/oauth/user_test.go new file mode 100644 index 00000000..14e8c4c7 --- /dev/null +++ b/api/oauth/user_test.go @@ -0,0 +1,178 @@ +package oauth_test + +import ( + "errors" + "net/url" + "testing" + + "github.com/SevereCloud/vksdk/v2/api/oauth" + "github.com/stretchr/testify/assert" +) + +func TestParseJSON(t *testing.T) { + t.Parallel() + + f := func(data []byte, wantToken *oauth.UserToken, wantErr string) { + token, err := oauth.NewUserTokenFromJSON(data) + if err != nil { + assert.EqualError(t, err, wantErr) + } + + assert.Equal(t, token, wantToken) + } + + f([]byte("123"), nil, "json: cannot unmarshal number into Go value of type oauth.Error") + f( + []byte(`{"access_token":"533bacf01e11f55b536a565b57531ac114461ae8736d6506a3", "expires_in":43200, "user_id":66748}`), + &oauth.UserToken{ + AccessToken: "533bacf01e11f55b536a565b57531ac114461ae8736d6506a3", + ExpiresIn: 43200, + UserID: 66748, + }, + "", + ) + f( + []byte(`{"error":"invalid_grant","error_description":"Code is expired."}`), + nil, + "oauth: Code is expired.", + ) +} + +func TestParseURL(t *testing.T) { + t.Parallel() + + f := func(rawurl string, wantToken *oauth.UserToken, wantErr string) { + t.Helper() + + u, _ := url.Parse(rawurl) + + token, err := oauth.NewUserTokenFromURL(u) + if err != nil { + assert.EqualError(t, err, wantErr) + } + + assert.Equal(t, token, wantToken) + } + + f( + "http://REDIRECT_URI#access_token=533bacf01e11f55b536a565b57531ad114461ae8736d6506a3&expires_in=86400&user_id=8492&state=6888183", + &oauth.UserToken{ + AccessToken: "533bacf01e11f55b536a565b57531ad114461ae8736d6506a3", + ExpiresIn: 86400, + UserID: 8492, + State: "6888183", + }, + "", + ) + f( + "http://REDIRECT_URI#error=access_denied&error_description=The+user+or+authorization+server+denied+the+request.", + nil, + "oauth: The user or authorization server denied the request.", + ) +} + +func TestParseURL_Error(t *testing.T) { + t.Parallel() + + _, err := oauth.NewUserTokenFromURL(&url.URL{ + Fragment: "%gh&%ij", + }) + assert.EqualError(t, err, `invalid URL escape "%gh"`) +} + +func TestAuthCodeFlowUser_URL(t *testing.T) { + t.Parallel() + + f := func(acf *oauth.AuthCodeFlowUser, wantURL string) { + t.Helper() + + assert.Equal(t, wantURL, acf.URL().String()) + } + + f(oauth.NewAuthCodeFlowUser(oauth.UserParams{ + ClientID: 6888183, + Scope: oauth.ScopeUserPhotos + oauth.ScopeUserDocs, + RedirectURI: "https://example.com/callback", + V: "5.100", + }, "clientSecret"), "https://oauth.vk.com/authorize?client_id=6888183&display=&redirect_uri=https%3A%2F%2Fexample.com%2Fcallback&response_type=code&scope=131076&state=&v=5.100") + f(oauth.NewAuthCodeFlowUser(oauth.UserParams{ + ClientID: 6888183, + Scope: oauth.ScopeUserPhotos + oauth.ScopeUserDocs, + }, "clientSecret"), "https://oauth.vk.com/authorize?client_id=6888183&display=&redirect_uri=https%3A%2F%2Foauth.vk.com%2Fblank.html&response_type=code&scope=131076&state=&v=5.131") +} + +func TestAuthCodeFlowUser_Token(t *testing.T) { + t.Parallel() + + acf := oauth.NewAuthCodeFlowUser(oauth.UserParams{ + ClientID: 6888183, + Scope: oauth.ScopeUserPhotos + oauth.ScopeUserDocs, + }, "clientSecretUser") + + u, _ := url.Parse("https://oauth.vk.com/blank.html#code=2fb239386220842e7c") + + _, err := acf.Token(u) + + var e *oauth.Error + if !errors.As(err, &e) { + t.Error(err) + } + + f := func(rawURL, errString string) { + t.Helper() + + u, _ := url.Parse(rawURL) + _, err := acf.Token(u) + assert.EqualError(t, err, errString) + } + + // f("https://oauth.vk.com/blank.html#;", "invalid semicolon separator in query") + f("https://oauth.vk.com/blank.html#error=invalid_request&error_description=Invalid+display+parameter", "oauth: Invalid display parameter") +} + +func TestImplicitFlowUser(t *testing.T) { + t.Parallel() + + f := func(p oauth.UserParams, wantURL string) { + t.Helper() + + assert.Equal(t, wantURL, oauth.ImplicitFlowUser(p).String()) + } + + f(oauth.UserParams{ + ClientID: 6888183, + Scope: oauth.ScopeUserPhotos + oauth.ScopeUserDocs, + RedirectURI: "https://example.com/callback", + V: "5.100", + }, "https://oauth.vk.com/authorize?client_id=6888183&display=&redirect_uri=https%3A%2F%2Fexample.com%2Fcallback&response_type=token&scope=131076&state=&v=5.100") + f(oauth.UserParams{ + ClientID: 6888183, + Scope: oauth.ScopeUserPhotos + oauth.ScopeUserDocs, + }, "https://oauth.vk.com/authorize?client_id=6888183&display=&redirect_uri=https%3A%2F%2Foauth.vk.com%2Fblank.html&response_type=token&scope=131076&state=&v=5.131") +} + +func TestDirectAuth(t *testing.T) { + t.Parallel() + + params := oauth.DirectAuthParams{ + ClientSecret: "test", + ClientID: 6888183, + Username: "username", + Password: "", + Scope: 0, + V: "5.131", + TwoFactorSupported: true, + ForceSMS: true, + Code: "code", + CaptchaSID: "captcha_sid", + CaptchaKey: "captcha_key", + TestRedirectURI: true, + } + + _, err := oauth.DirectAuth(params) + + var e *oauth.Error + if !errors.As(err, &e) { + t.Error(err) + } +}