Skip to content

Commit 0155fee

Browse files
committed
feat: add VK ID provider
1 parent b162897 commit 0155fee

File tree

5 files changed

+232
-15
lines changed

5 files changed

+232
-15
lines changed

embedx/config.schema.json

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -489,7 +489,7 @@
489489
},
490490
"provider": {
491491
"title": "Provider",
492-
"description": "Can be one of github, github-app, gitlab, generic, google, microsoft, discord, salesforce, slack, facebook, auth0, vk, yandex, apple, spotify, netid, dingtalk, patreon.",
492+
"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.",
493493
"type": "string",
494494
"enum": [
495495
"github",
@@ -504,6 +504,7 @@
504504
"facebook",
505505
"auth0",
506506
"vk",
507+
"vkid",
507508
"yandex",
508509
"apple",
509510
"spotify",
@@ -668,6 +669,17 @@
668669
],
669670
"default": "auto"
670671
},
672+
"pass_callback_params": {
673+
"title": "Pass callback Parameters",
674+
"description": "Specifies which query parameters from the callback request should be forwarded to the token endpoint request",
675+
"type": "array",
676+
"items": {
677+
"type": "string",
678+
"examples": [
679+
"device_id"
680+
]
681+
}
682+
},
671683
"fedcm_config_url": {
672684
"title": "Federation Configuration URL",
673685
"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 @@
684696
"examples": [
685697
"https://example.com"
686698
]
699+
},
700+
"vkid_provider_param": {
701+
"title": "VKID Provider Parameter",
702+
"description": "Sets the \"provider\" query parameter for VK ID authentication",
703+
"enum": [
704+
"vkid",
705+
"ok_ru",
706+
"mail_ru"
707+
]
687708
}
688709
},
689710
"additionalProperties": false,

selfservice/strategy/oidc/provider_config.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ type Configuration struct {
3232
// - facebook
3333
// - auth0
3434
// - vk
35+
// - vkid
3536
// - yandex
3637
// - apple
3738
// - spotify
@@ -130,13 +131,22 @@ type Configuration struct {
130131
// (Note the missing <provider> path segment and no trailing slash).
131132
PKCE string `json:"pkce"`
132133

134+
// PassCallbackParams specifies which query parameters from the callback request
135+
// should be forwarded to the token endpoint request.
136+
PassCallbackParams []string `json:"pass_callback_params"`
137+
133138
// FedCMConfigURL is the URL to the FedCM IdP configuration file.
134139
// This is only effective in the Ory Network.
135140
FedCMConfigURL string `json:"fedcm_config_url"`
136141

137142
// NetIDTokenOriginHeader contains the orgin header to be used when exchanging a
138143
// NetID FedCM token for an ID token.
139144
NetIDTokenOriginHeader string `json:"net_id_token_origin_header"`
145+
146+
// VKIDProviderParam sets the "provider" query parameter for VK ID authentication.
147+
// Default is "vkid". Use "ok_ru" or "mail_ru" to authenticate users via Odnoklassniki or Mail.ru instead of VK ID.
148+
// See: https://id.vk.com/about/business/go/docs/en/vkid/latest/vk-id/intro/main#Through-third-party-OAuth-services
149+
VKIDProviderParam string `json:"vkid_provider_param"`
140150
}
141151

142152
func (p Configuration) Redir(public *url.URL) string {
@@ -176,6 +186,7 @@ var supportedProviders = map[string]func(config *Configuration, reg Dependencies
176186
"facebook": NewProviderFacebook,
177187
"auth0": NewProviderAuth0,
178188
"vk": NewProviderVK,
189+
"vkid": NewProviderVKID,
179190
"yandex": NewProviderYandex,
180191
"apple": NewProviderApple,
181192
"spotify": NewProviderSpotify,

selfservice/strategy/oidc/provider_userinfo_test.go

Lines changed: 51 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -69,15 +69,16 @@ func TestProviderClaimsRespectsErrorCodes(t *testing.T) {
6969
}
7070

7171
for _, tc := range []struct {
72-
name string
73-
issuer string
74-
userInfoEndpoint string
75-
config *oidc.Configuration
76-
provider oidc.Provider
77-
userInfoHandler func(req *http.Request) (*http.Response, error)
78-
expectedClaims *oidc.Claims
79-
useToken *oauth2.Token
80-
hook func(t *testing.T)
72+
name string
73+
issuer string
74+
userInfoEndpoint string
75+
config *oidc.Configuration
76+
provider oidc.Provider
77+
userInfoHandler func(req *http.Request) (*http.Response, error)
78+
userInfoHandlerMethod string
79+
expectedClaims *oidc.Claims
80+
useToken *oauth2.Token
81+
hook func(t *testing.T)
8182
}{
8283
{
8384
name: "auth0",
@@ -164,13 +165,42 @@ func TestProviderClaimsRespectsErrorCodes(t *testing.T) {
164165
165166
},
166167
},
168+
{
169+
name: "vkid",
170+
userInfoEndpoint: "https://id.vk.com/oauth2/user_info",
171+
provider: oidc.NewProviderVKID(&oidc.Configuration{
172+
IssuerURL: "https://id.vk.com",
173+
ID: "vkid",
174+
Provider: "vkid",
175+
ClientID: "foo",
176+
}, reg),
177+
useToken: token,
178+
userInfoHandlerMethod: http.MethodPost,
179+
userInfoHandler: func(req *http.Request) (*http.Response, error) {
180+
if head := req.URL.Query().Get("access_token"); len(head) == 0 {
181+
resp, err := httpmock.NewJsonResponse(401, map[string]interface{}{"error": ""})
182+
return resp, err
183+
}
184+
185+
resp, err := httpmock.NewJsonResponse(200, map[string]interface{}{
186+
"user": map[string]interface{}{"user_id": 123456789012345, "email": "[email protected]"},
187+
})
188+
return resp, err
189+
},
190+
expectedClaims: &oidc.Claims{
191+
Issuer: "https://id.vk.com/oauth2/user_info",
192+
Subject: "123456789012345",
193+
194+
EmailVerified: true,
195+
},
196+
},
167197
{
168198
name: "yandex",
169199
userInfoEndpoint: "https://login.yandex.ru/info",
170200
provider: oidc.NewProviderYandex(&oidc.Configuration{
171201
IssuerURL: "https://oauth.yandex.com",
172-
ID: "vk",
173-
Provider: "vk",
202+
ID: "yandex",
203+
Provider: "yandex",
174204
}, reg),
175205
useToken: token.WithExtra(map[string]interface{}{"email": "[email protected]"}),
176206
userInfoHandler: func(req *http.Request) (*http.Response, error) {
@@ -348,7 +378,11 @@ func TestProviderClaimsRespectsErrorCodes(t *testing.T) {
348378
tc.hook(t)
349379
}
350380

351-
httpmock.RegisterResponder("GET", tc.userInfoEndpoint, func(req *http.Request) (*http.Response, error) {
381+
userInfoHandlerMethod := tc.userInfoHandlerMethod
382+
if userInfoHandlerMethod == "" {
383+
userInfoHandlerMethod = http.MethodGet
384+
}
385+
httpmock.RegisterResponder(userInfoHandlerMethod, tc.userInfoEndpoint, func(req *http.Request) (*http.Response, error) {
352386
return httpmock.NewJsonResponse(455, map[string]interface{}{})
353387
})
354388

@@ -366,7 +400,11 @@ func TestProviderClaimsRespectsErrorCodes(t *testing.T) {
366400
tc.hook(t)
367401
}
368402

369-
httpmock.RegisterResponder("GET", tc.userInfoEndpoint, tc.userInfoHandler)
403+
userInfoHandlerMethod := tc.userInfoHandlerMethod
404+
if userInfoHandlerMethod == "" {
405+
userInfoHandlerMethod = http.MethodGet
406+
}
407+
httpmock.RegisterResponder(userInfoHandlerMethod, tc.userInfoEndpoint, tc.userInfoHandler)
370408

371409
claims, err := tc.provider.(oidc.OAuth2Provider).Claims(ctx, token, url.Values{})
372410
require.NoError(t, err)
Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
// Copyright © 2025 Ory Corp
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
package oidc
5+
6+
import (
7+
"context"
8+
"encoding/json"
9+
"net/url"
10+
11+
"github.com/hashicorp/go-retryablehttp"
12+
"github.com/pkg/errors"
13+
"golang.org/x/oauth2"
14+
15+
"github.com/ory/herodot"
16+
"github.com/ory/x/httpx"
17+
)
18+
19+
var _ OAuth2Provider = (*ProviderVKID)(nil)
20+
21+
type ProviderVKID struct {
22+
config *Configuration
23+
reg Dependencies
24+
}
25+
26+
func NewProviderVKID(
27+
config *Configuration,
28+
reg Dependencies,
29+
) Provider {
30+
// This is required for all apps when the authorization code is exchanged for tokens.
31+
// See: https://id.vk.com/about/business/go/docs/en/vkid/latest/vk-id/connection/api-integration/realization
32+
config.PKCE = "force"
33+
// A unique device ID. The client must save this ID and pass it in subsequent requests to the authorization server.
34+
config.PassCallbackParams = []string{"device_id"}
35+
36+
return &ProviderVKID{
37+
config: config,
38+
reg: reg,
39+
}
40+
}
41+
42+
func (p *ProviderVKID) Config() *Configuration {
43+
return p.config
44+
}
45+
46+
func (p *ProviderVKID) oauth2(ctx context.Context) *oauth2.Config {
47+
return &oauth2.Config{
48+
ClientID: p.config.ClientID,
49+
ClientSecret: p.config.ClientSecret,
50+
Endpoint: oauth2.Endpoint{
51+
AuthURL: "https://id.vk.com/authorize",
52+
TokenURL: "https://id.vk.com/oauth2/auth",
53+
},
54+
Scopes: p.config.Scope,
55+
RedirectURL: p.config.Redir(p.reg.Config().OIDCRedirectURIBase(ctx)),
56+
}
57+
}
58+
59+
func (p *ProviderVKID) AuthCodeURLOptions(r ider) []oauth2.AuthCodeOption {
60+
var opts []oauth2.AuthCodeOption
61+
if p.config.VKIDProviderParam != "" {
62+
opts = append(opts, oauth2.SetAuthURLParam("provider", p.config.VKIDProviderParam))
63+
}
64+
return opts
65+
}
66+
67+
func (p *ProviderVKID) OAuth2(ctx context.Context) (*oauth2.Config, error) {
68+
return p.oauth2(ctx), nil
69+
}
70+
71+
func (p *ProviderVKID) Claims(ctx context.Context, exchange *oauth2.Token, query url.Values) (*Claims, error) {
72+
o, err := p.OAuth2(ctx)
73+
if err != nil {
74+
return nil, errors.WithStack(herodot.ErrInternalServerError.WithReasonf("%s", err))
75+
}
76+
77+
ctx, client := httpx.SetOAuth2(ctx, p.reg.HTTPClient(ctx), o, exchange)
78+
req, err := retryablehttp.NewRequestWithContext(ctx, "POST", "https://id.vk.com/oauth2/user_info?client_id="+p.config.ClientID+"&access_token="+exchange.AccessToken, nil)
79+
if err != nil {
80+
return nil, errors.WithStack(herodot.ErrInternalServerError.WithReasonf("%s", err))
81+
}
82+
83+
resp, err := client.Do(req)
84+
if err != nil {
85+
return nil, errors.WithStack(herodot.ErrInternalServerError.WithReasonf("%s", err))
86+
}
87+
defer resp.Body.Close()
88+
89+
if err := logUpstreamError(p.reg.Logger(), resp); err != nil {
90+
return nil, err
91+
}
92+
93+
type User struct {
94+
UserId string `json:"user_id,omitempty"`
95+
FirstName string `json:"first_name,omitempty"`
96+
LastName string `json:"last_name,omitempty"`
97+
Phone string `json:"phone,omitempty"`
98+
Avatar string `json:"avatar,omitempty"`
99+
Email string `json:"email,omitempty"`
100+
Gender int `json:"sex,omitempty"`
101+
BirthDay string `json:"birthday,omitempty"`
102+
}
103+
104+
var response struct {
105+
User User `json:"user,omitempty"`
106+
}
107+
108+
if err := json.NewDecoder(resp.Body).Decode(&response); err != nil {
109+
return nil, errors.WithStack(herodot.ErrInternalServerError.WithReasonf("%s", err))
110+
}
111+
112+
if response.User.UserId == "" {
113+
return nil, errors.WithStack(herodot.ErrInternalServerError.WithReasonf("VK ID did not return a user id in the user_info request."))
114+
}
115+
116+
gender := ""
117+
switch response.User.Gender {
118+
case 1:
119+
gender = "female"
120+
case 2:
121+
gender = "male"
122+
}
123+
124+
return &Claims{
125+
Issuer: "https://id.vk.com/oauth2/user_info",
126+
Subject: response.User.UserId,
127+
GivenName: response.User.FirstName,
128+
FamilyName: response.User.LastName,
129+
Picture: response.User.Avatar,
130+
Email: response.User.Email,
131+
EmailVerified: response.User.Email != "", // VK ID returns only verified email
132+
PhoneNumber: response.User.Phone,
133+
PhoneNumberVerified: response.User.Phone != "", // VK ID returns only verified phone number
134+
Gender: gender,
135+
Birthdate: response.User.BirthDay,
136+
}, nil
137+
}

selfservice/strategy/oidc/strategy.go

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -479,7 +479,7 @@ func (s *Strategy) HandleCallback(w http.ResponseWriter, r *http.Request, ps htt
479479
var et *identity.CredentialsOIDCEncryptedTokens
480480
switch p := provider.(type) {
481481
case OAuth2Provider:
482-
token, err := s.exchangeCode(ctx, p, code, PKCEVerifier(state))
482+
token, err := s.exchangeCode(ctx, p, code, s.buildExchangeCodeOpts(p.Config(), r, PKCEVerifier(state)))
483483
if err != nil {
484484
s.forwardError(ctx, w, r, req, s.HandleError(ctx, w, r, req, state.ProviderId, nil, err))
485485
return
@@ -563,6 +563,16 @@ func (s *Strategy) HandleCallback(w http.ResponseWriter, r *http.Request, ps htt
563563
}
564564
}
565565

566+
func (s *Strategy) buildExchangeCodeOpts(cfg *Configuration, r *http.Request, verifier []oauth2.AuthCodeOption) []oauth2.AuthCodeOption {
567+
var opts []oauth2.AuthCodeOption
568+
for _, paramName := range cfg.PassCallbackParams {
569+
if paramValue := r.URL.Query().Get(paramName); paramValue != "" {
570+
opts = append(opts, oauth2.SetAuthURLParam(paramName, paramValue))
571+
}
572+
}
573+
return append(opts, verifier...)
574+
}
575+
566576
func (s *Strategy) exchangeCode(ctx context.Context, provider OAuth2Provider, code string, opts []oauth2.AuthCodeOption) (token *oauth2.Token, err error) {
567577
ctx, span := s.d.Tracer(ctx).Tracer().Start(ctx, "strategy.oidc.exchangeCode", trace.WithAttributes(
568578
attribute.String("provider_id", provider.Config().ID),

0 commit comments

Comments
 (0)