From da097d900979ac271159eaa55ed3b0358bcc5b00 Mon Sep 17 00:00:00 2001 From: Pavel Afanasev Date: Sat, 3 May 2025 14:50:39 +0300 Subject: [PATCH] feat: add VK ID provider --- embedx/config.schema.json | 23 ++- selfservice/strategy/oidc/provider_config.go | 11 ++ .../oidc/provider_private_net_test.go | 1 + .../strategy/oidc/provider_userinfo_test.go | 64 ++++++-- selfservice/strategy/oidc/provider_vkid.go | 137 ++++++++++++++++++ selfservice/strategy/oidc/strategy.go | 12 +- 6 files changed, 233 insertions(+), 15 deletions(-) create mode 100644 selfservice/strategy/oidc/provider_vkid.go diff --git a/embedx/config.schema.json b/embedx/config.schema.json index f8a441eccc7e..808c6cecdc52 100644 --- a/embedx/config.schema.json +++ b/embedx/config.schema.json @@ -489,7 +489,7 @@ }, "provider": { "title": "Provider", - "description": "Can be one of github, github-app, gitlab, generic, google, microsoft, discord, salesforce, slack, facebook, auth0, vk, yandex, apple, spotify, netid, dingtalk, patreon.", + "description": "Can be one of github, github-app, gitlab, generic, google, microsoft, discord, salesforce, slack, facebook, auth0, vk, vkid, yandex, apple, spotify, netid, dingtalk, patreon.", "type": "string", "enum": [ "github", @@ -504,6 +504,7 @@ "facebook", "auth0", "vk", + "vkid", "yandex", "apple", "spotify", @@ -668,6 +669,17 @@ ], "default": "auto" }, + "pass_callback_params": { + "title": "Pass callback Parameters", + "description": "Specifies which query parameters from the callback request should be forwarded to the token endpoint request", + "type": "array", + "items": { + "type": "string", + "examples": [ + "device_id" + ] + } + }, "fedcm_config_url": { "title": "Federation Configuration URL", "description": "The URL where the FedCM IdP configuration is located for the provider. This is only effective in the Ory Network.", @@ -684,6 +696,15 @@ "examples": [ "https://example.com" ] + }, + "vkid_provider_param": { + "title": "VK ID Provider Parameter", + "description": "Sets the \"provider\" query parameter for VK ID authentication", + "enum": [ + "vkid", + "ok_ru", + "mail_ru" + ] } }, "additionalProperties": false, diff --git a/selfservice/strategy/oidc/provider_config.go b/selfservice/strategy/oidc/provider_config.go index d86a3b43b0f4..7f1a860f9109 100644 --- a/selfservice/strategy/oidc/provider_config.go +++ b/selfservice/strategy/oidc/provider_config.go @@ -32,6 +32,7 @@ type Configuration struct { // - facebook // - auth0 // - vk + // - vkid // - yandex // - apple // - spotify @@ -130,6 +131,10 @@ type Configuration struct { // (Note the missing path segment and no trailing slash). PKCE string `json:"pkce"` + // PassCallbackParams specifies which query parameters from the callback request + // should be forwarded to the token endpoint request. + PassCallbackParams []string `json:"pass_callback_params"` + // FedCMConfigURL is the URL to the FedCM IdP configuration file. // This is only effective in the Ory Network. FedCMConfigURL string `json:"fedcm_config_url"` @@ -137,6 +142,11 @@ type Configuration struct { // NetIDTokenOriginHeader contains the orgin header to be used when exchanging a // NetID FedCM token for an ID token. NetIDTokenOriginHeader string `json:"net_id_token_origin_header"` + + // VKIDProviderParam sets the "provider" query parameter for VK ID authentication. + // Default is "vkid". Use "ok_ru" or "mail_ru" to authenticate users via Odnoklassniki or Mail.ru instead of VK ID. + // See: https://id.vk.com/about/business/go/docs/en/vkid/latest/vk-id/intro/main#Through-third-party-OAuth-services + VKIDProviderParam string `json:"vkid_provider_param"` } func (p Configuration) Redir(public *url.URL) string { @@ -176,6 +186,7 @@ var supportedProviders = map[string]func(config *Configuration, reg Dependencies "facebook": NewProviderFacebook, "auth0": NewProviderAuth0, "vk": NewProviderVK, + "vkid": NewProviderVKID, "yandex": NewProviderYandex, "apple": NewProviderApple, "spotify": NewProviderSpotify, diff --git a/selfservice/strategy/oidc/provider_private_net_test.go b/selfservice/strategy/oidc/provider_private_net_test.go index 0505a3e19626..e46c9d3263ea 100644 --- a/selfservice/strategy/oidc/provider_private_net_test.go +++ b/selfservice/strategy/oidc/provider_private_net_test.go @@ -83,6 +83,7 @@ func TestProviderPrivateIP(t *testing.T) { // Slack uses a fixed token URL and does not use the issuer. // Spotify uses a fixed token URL and does not use the issuer. // VK uses a fixed token URL and does not use the issuer. + // VK ID uses a fixed token URL and does not use the issuer. // Yandex uses a fixed token URL and does not use the issuer. // NetID uses a fixed token URL and does not use the issuer. // X uses a fixed token URL and userinfoRL and does not use the issuer value. diff --git a/selfservice/strategy/oidc/provider_userinfo_test.go b/selfservice/strategy/oidc/provider_userinfo_test.go index 9eb27914541e..fb499aa511ee 100644 --- a/selfservice/strategy/oidc/provider_userinfo_test.go +++ b/selfservice/strategy/oidc/provider_userinfo_test.go @@ -69,15 +69,16 @@ func TestProviderClaimsRespectsErrorCodes(t *testing.T) { } for _, tc := range []struct { - name string - issuer string - userInfoEndpoint string - config *oidc.Configuration - provider oidc.Provider - userInfoHandler func(req *http.Request) (*http.Response, error) - expectedClaims *oidc.Claims - useToken *oauth2.Token - hook func(t *testing.T) + name string + issuer string + userInfoEndpoint string + config *oidc.Configuration + provider oidc.Provider + userInfoHandler func(req *http.Request) (*http.Response, error) + userInfoHandlerMethod string + expectedClaims *oidc.Claims + useToken *oauth2.Token + hook func(t *testing.T) }{ { name: "auth0", @@ -164,13 +165,42 @@ func TestProviderClaimsRespectsErrorCodes(t *testing.T) { Email: "john.doe@example.com", }, }, + { + name: "vkid", + userInfoEndpoint: "https://id.vk.com/oauth2/user_info", + provider: oidc.NewProviderVKID(&oidc.Configuration{ + IssuerURL: "https://id.vk.com", + ID: "vkid", + Provider: "vkid", + ClientID: "foo", + }, reg), + useToken: token, + userInfoHandlerMethod: http.MethodPost, + userInfoHandler: func(req *http.Request) (*http.Response, error) { + if head := req.URL.Query().Get("access_token"); len(head) == 0 { + resp, err := httpmock.NewJsonResponse(401, map[string]interface{}{"error": ""}) + return resp, err + } + + resp, err := httpmock.NewJsonResponse(200, map[string]interface{}{ + "user": map[string]interface{}{"user_id": "123456789012345", "email": "john.doe@example.com"}, + }) + return resp, err + }, + expectedClaims: &oidc.Claims{ + Issuer: "https://id.vk.com/oauth2/user_info", + Subject: "123456789012345", + Email: "john.doe@example.com", + EmailVerified: true, + }, + }, { name: "yandex", userInfoEndpoint: "https://login.yandex.ru/info", provider: oidc.NewProviderYandex(&oidc.Configuration{ IssuerURL: "https://oauth.yandex.com", - ID: "vk", - Provider: "vk", + ID: "yandex", + Provider: "yandex", }, reg), useToken: token.WithExtra(map[string]interface{}{"email": "john.doe@example.com"}), userInfoHandler: func(req *http.Request) (*http.Response, error) { @@ -348,7 +378,11 @@ func TestProviderClaimsRespectsErrorCodes(t *testing.T) { tc.hook(t) } - httpmock.RegisterResponder("GET", tc.userInfoEndpoint, func(req *http.Request) (*http.Response, error) { + userInfoHandlerMethod := tc.userInfoHandlerMethod + if userInfoHandlerMethod == "" { + userInfoHandlerMethod = http.MethodGet + } + httpmock.RegisterResponder(userInfoHandlerMethod, tc.userInfoEndpoint, func(req *http.Request) (*http.Response, error) { return httpmock.NewJsonResponse(455, map[string]interface{}{}) }) @@ -366,7 +400,11 @@ func TestProviderClaimsRespectsErrorCodes(t *testing.T) { tc.hook(t) } - httpmock.RegisterResponder("GET", tc.userInfoEndpoint, tc.userInfoHandler) + userInfoHandlerMethod := tc.userInfoHandlerMethod + if userInfoHandlerMethod == "" { + userInfoHandlerMethod = http.MethodGet + } + httpmock.RegisterResponder(userInfoHandlerMethod, tc.userInfoEndpoint, tc.userInfoHandler) claims, err := tc.provider.(oidc.OAuth2Provider).Claims(ctx, token, url.Values{}) require.NoError(t, err) diff --git a/selfservice/strategy/oidc/provider_vkid.go b/selfservice/strategy/oidc/provider_vkid.go new file mode 100644 index 000000000000..5e06f774f81f --- /dev/null +++ b/selfservice/strategy/oidc/provider_vkid.go @@ -0,0 +1,137 @@ +// Copyright © 2025 Ory Corp +// SPDX-License-Identifier: Apache-2.0 + +package oidc + +import ( + "context" + "encoding/json" + "net/url" + + "github.com/hashicorp/go-retryablehttp" + "github.com/pkg/errors" + "golang.org/x/oauth2" + + "github.com/ory/herodot" + "github.com/ory/x/httpx" +) + +var _ OAuth2Provider = (*ProviderVKID)(nil) + +type ProviderVKID struct { + config *Configuration + reg Dependencies +} + +func NewProviderVKID( + config *Configuration, + reg Dependencies, +) Provider { + // This is required for all apps when the authorization code is exchanged for tokens. + // See: https://id.vk.com/about/business/go/docs/en/vkid/latest/vk-id/connection/api-integration/realization + config.PKCE = "force" + // A unique device ID. The client must save this ID and pass it in subsequent requests to the authorization server. + config.PassCallbackParams = []string{"device_id"} + + return &ProviderVKID{ + config: config, + reg: reg, + } +} + +func (p *ProviderVKID) Config() *Configuration { + return p.config +} + +func (p *ProviderVKID) oauth2(ctx context.Context) *oauth2.Config { + return &oauth2.Config{ + ClientID: p.config.ClientID, + ClientSecret: p.config.ClientSecret, + Endpoint: oauth2.Endpoint{ + AuthURL: "https://id.vk.com/authorize", + TokenURL: "https://id.vk.com/oauth2/auth", + }, + Scopes: p.config.Scope, + RedirectURL: p.config.Redir(p.reg.Config().OIDCRedirectURIBase(ctx)), + } +} + +func (p *ProviderVKID) AuthCodeURLOptions(r ider) []oauth2.AuthCodeOption { + var opts []oauth2.AuthCodeOption + if p.config.VKIDProviderParam != "" { + opts = append(opts, oauth2.SetAuthURLParam("provider", p.config.VKIDProviderParam)) + } + return opts +} + +func (p *ProviderVKID) OAuth2(ctx context.Context) (*oauth2.Config, error) { + return p.oauth2(ctx), nil +} + +func (p *ProviderVKID) Claims(ctx context.Context, exchange *oauth2.Token, query url.Values) (*Claims, error) { + o, err := p.OAuth2(ctx) + if err != nil { + return nil, errors.WithStack(herodot.ErrInternalServerError.WithReasonf("%s", err)) + } + + ctx, client := httpx.SetOAuth2(ctx, p.reg.HTTPClient(ctx), o, exchange) + req, err := retryablehttp.NewRequestWithContext(ctx, "POST", "https://id.vk.com/oauth2/user_info?client_id="+p.config.ClientID+"&access_token="+exchange.AccessToken, nil) + if err != nil { + return nil, errors.WithStack(herodot.ErrInternalServerError.WithReasonf("%s", err)) + } + + resp, err := client.Do(req) + if err != nil { + return nil, errors.WithStack(herodot.ErrInternalServerError.WithReasonf("%s", err)) + } + defer resp.Body.Close() + + if err := logUpstreamError(p.reg.Logger(), resp); err != nil { + return nil, err + } + + type User struct { + UserId string `json:"user_id,omitempty"` + FirstName string `json:"first_name,omitempty"` + LastName string `json:"last_name,omitempty"` + Phone string `json:"phone,omitempty"` + Avatar string `json:"avatar,omitempty"` + Email string `json:"email,omitempty"` + Gender int `json:"sex,omitempty"` + BirthDay string `json:"birthday,omitempty"` + } + + var response struct { + User User `json:"user,omitempty"` + } + + if err := json.NewDecoder(resp.Body).Decode(&response); err != nil { + return nil, errors.WithStack(herodot.ErrInternalServerError.WithReasonf("%s", err)) + } + + if response.User.UserId == "" { + return nil, errors.WithStack(herodot.ErrInternalServerError.WithReasonf("VK ID did not return a user id in the user_info request.")) + } + + gender := "" + switch response.User.Gender { + case 1: + gender = "female" + case 2: + gender = "male" + } + + return &Claims{ + Issuer: "https://id.vk.com/oauth2/user_info", + Subject: response.User.UserId, + GivenName: response.User.FirstName, + FamilyName: response.User.LastName, + Picture: response.User.Avatar, + Email: response.User.Email, + EmailVerified: response.User.Email != "", // VK ID returns only verified email + PhoneNumber: response.User.Phone, + PhoneNumberVerified: response.User.Phone != "", // VK ID returns only verified phone number + Gender: gender, + Birthdate: response.User.BirthDay, + }, nil +} diff --git a/selfservice/strategy/oidc/strategy.go b/selfservice/strategy/oidc/strategy.go index 918f1a1b41ce..5978f32e27fb 100644 --- a/selfservice/strategy/oidc/strategy.go +++ b/selfservice/strategy/oidc/strategy.go @@ -479,7 +479,7 @@ func (s *Strategy) HandleCallback(w http.ResponseWriter, r *http.Request, ps htt var et *identity.CredentialsOIDCEncryptedTokens switch p := provider.(type) { case OAuth2Provider: - token, err := s.exchangeCode(ctx, p, code, PKCEVerifier(state)) + token, err := s.exchangeCode(ctx, p, code, s.buildExchangeCodeOpts(p.Config(), r, PKCEVerifier(state))) if err != nil { s.forwardError(ctx, w, r, req, s.HandleError(ctx, w, r, req, state.ProviderId, nil, err)) return @@ -563,6 +563,16 @@ func (s *Strategy) HandleCallback(w http.ResponseWriter, r *http.Request, ps htt } } +func (s *Strategy) buildExchangeCodeOpts(cfg *Configuration, r *http.Request, verifier []oauth2.AuthCodeOption) []oauth2.AuthCodeOption { + var opts []oauth2.AuthCodeOption + for _, paramName := range cfg.PassCallbackParams { + if paramValue := r.URL.Query().Get(paramName); paramValue != "" { + opts = append(opts, oauth2.SetAuthURLParam(paramName, paramValue)) + } + } + return append(opts, verifier...) +} + func (s *Strategy) exchangeCode(ctx context.Context, provider OAuth2Provider, code string, opts []oauth2.AuthCodeOption) (token *oauth2.Token, err error) { ctx, span := s.d.Tracer(ctx).Tracer().Start(ctx, "strategy.oidc.exchangeCode", trace.WithAttributes( attribute.String("provider_id", provider.Config().ID),