8000 feat: add VK ID provider by afansv · Pull Request #4400 · ory/kratos · GitHub
[go: up one dir, main page]
More Web Proxy on the site http://driver.im/
Skip to content

feat: add VK ID provider #4400

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 4 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 22 additions & 1 deletion embedx/config.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -504,6 +504,7 @@
"facebook",
"auth0",
"vk",
"vkid",
"yandex",
"apple",
"spotify",
Expand Down Expand Up @@ -669,6 +670,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.",
Expand All @@ -685,6 +697,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,
Expand Down
11 changes: 11 additions & 0 deletions selfservice/strategy/oidc/provider_config.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ type Configuration struct {
// - facebook
// - auth0
// - vk
// - vkid
// - yandex
// - apple
// - spotify
Expand Down Expand Up @@ -130,13 +131,22 @@ type Configuration struct {
// (Note the missing <provider> 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"`

// 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 {
Expand Down Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions selfservice/strategy/oidc/provider_private_net_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
64 changes: 51 additions & 13 deletions selfservice/strategy/oidc/provider_userinfo_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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{}{})
})

Expand All @@ -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)
Expand Down
137 changes: 137 additions & 0 deletions selfservice/strategy/oidc/provider_vkid.go
Original file line number Diff line number Diff line change
@@ -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
}
12 changes: 11 additions & 1 deletion selfservice/strategy/oidc/strategy.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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),
Expand Down
Loading
0