Skip to content
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

Add Yandex and VK ID web providers #2244

Merged
merged 24 commits into from
Feb 10, 2025
Merged

Add Yandex and VK ID web providers #2244

merged 24 commits into from
Feb 10, 2025

Conversation

t1moH1ch
Copy link
Contributor

Hello!

Added support for VK ID and for Yandex

API documentation for VK ID is available at this link
API documentation for Yandex is available at this link

@kevinchalet
Copy link
Member

Hey,

Thanks for your PR. Were you able to confirm both providers are working fine? Can you please post a screenshot of the resulting claims table for both providers? (feel free to blur the most sensitive details)

Cheers.

@t1moH1ch
Copy link
Contributor Author

Hey,

Thanks for your PR. Were you able to confirm both providers are working fine? Can you please post a screenshot of the resulting claims table for both providers? (feel free to blur the most sensitive details)

Cheers.

The result for Yandex
yandex
and for VK ID
vkid

@t1moH1ch
Copy link
Contributor Author

I sent an invitation to the DefaultIdentityDict test project to check the driver's performance.

Documentation="https://yandex.ru/dev/id/doc/en/">
<Environment Issuer="https://oauth.yandex.ru/">
<Configuration AuthorizationEndpoint="https://oauth.yandex.ru/authorize"
RevokationEndpoint="https://oauth.yandex.ru/revoke_token"
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Assuming Yandex's documentation is correct, their endpoint is not a standard OAuth 2.0 revocation endpoint: it uses an access_token parameter instead of the standard token (see https://yandex.ru/dev/id/doc/en/tokens/token-invalidate).

Can you please try with the sandbox console app to see if it works or not? If it doesn't, I'll add an event handler to take care of the standard -> non-standard parameter mapping.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Did you give it a try? 😃

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Mmm, the token update method returned an error
yandex

VK ID doesn`t allow any ports except 443.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Mmm, the token update method returned an error

Ah yeah, sorry: that's because the two <GrantType> nodes are not under <Configuration> (they are currently under <Environment>, which is not valid). Fix that and the error should go away.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

VK ID doesn`t allow any ports except 443.

According to their documentation, it seems they also allow HTTP port 80 during development:

image

You can force the OpenIddict.Sandbox.Console.Client sample to listen on specific ports by using options.UseSystemIntegration().SetAllowedEmbeddedWebServerPorts(80). If you get an error, make sure the port is not busy/taken by a different app when starting the demo app.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adding the authentication property as mentioned in the message doesn't work? See #2244 (comment)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry, my mistake. All works fine👍

Copy link
Contributor Author

@t1moH1ch t1moH1ch Feb 10, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

else if (context.Registration.ProviderType is ProviderTypes.VkId)
{
    context.TokenRequest["device_id"] = context.GrantType switch
    {
        GrantTypes.AuthorizationCode or GrantTypes.Implicit => context.Request["device_id"],

        _ when context.Properties.TryGetValue(VkId.Properties.DeviceId, out string? identifier) &&
            !string.IsNullOrEmpty(identifier) => identifier,

        _ => throw new InvalidOperationException(SR.GetResourceString(SR.ID0467))
    };
}

Hmmm, for now this code make an exception. For refresh method only in VK ID

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmmm, for now this code make an exception. For refresh method only

You added #2244 (comment) so the device identifier is attached as an authentication property, right?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, it's all ok. Sorry please, i use previous version - this missed. I checked everything again, all works fine 👍

@kevinchalet
Copy link
Member

The result for VK ID vkid

Hum, VK ID wraps userinfo responses in a user node. Can you please update this handler to include VK ID?

// Fitbit, Todoist and Zendesk return a nested "user" object.
ProviderTypes.Fitbit or ProviderTypes.Todoist or ProviderTypes.Zendesk
=> new(context.Response["user"]?.GetNamedParameters() ??
throw new InvalidOperationException(SR.FormatID0334("user"))),

Note: since these providers don't use the standard OIDC userinfo claims, you'll also need to update this handler to map the claims:

/// <summary>
/// Contains the logic responsible for mapping select custom claims to
/// their WS-Federation equivalent for the providers that require it.
/// </summary>
public sealed class MapCustomWebServicesFederationClaims : IOpenIddictClientHandler<ProcessAuthenticationContext>
{
/// <summary>
/// Gets the default descriptor definition assigned to this handler.
/// </summary>
public static OpenIddictClientHandlerDescriptor Descriptor { get; }
= OpenIddictClientHandlerDescriptor.CreateBuilder<ProcessAuthenticationContext>()
.AddFilter<RequireWebServicesFederationClaimMappingEnabled>()
.UseSingletonHandler<MapCustomWebServicesFederationClaims>()
.SetOrder(MapStandardWebServicesFederationClaims.Descriptor.Order + 1_000)
.SetType(OpenIddictClientHandlerType.BuiltIn)
.Build();
/// <inheritdoc/>
public ValueTask HandleAsync(ProcessAuthenticationContext context)
{
if (context is null)
{
throw new ArgumentNullException(nameof(context));
}
Debug.Assert(context.Registration.Issuer is { IsAbsoluteUri: true }, SR.GetResourceString(SR.ID4013));
// As an OpenID Connect framework, the OpenIddict client mostly uses the claim set defined by the OpenID
// Connect core specification (https://openid.net/specs/openid-connect-core-1_0.html#StandardClaims).
// While these claims can be easily accessed using their standard OIDC name, many components still use
// the Web Services Federation claims exposed by the BCL ClaimTypes class, sometimes without allowing
// to use different claim types (e.g ASP.NET Core Identity hardcodes ClaimTypes.NameIdentifier in a few
// places, like the GetUserId() extension). To reduce the difficulty of using the OpenIddict client with
// these components relying on WS-Federation-style claims, these claims are mapped from the custom,
// provider-specific parameters (either from the userinfo response or from the token rensponse).
//
// Note: a similar event handler exists in OpenIddict.Client to map these claims from
// the standard OpenID Connect claim types (see MapStandardWebServicesFederationClaims).
var issuer = context.Registration.ClaimsIssuer ??
context.Registration.ProviderName ??
context.Registration.Issuer.AbsoluteUri;
context.MergedPrincipal.SetClaim(ClaimTypes.Email, issuer: issuer, value: context.Registration.ProviderType switch
{
// Basecamp returns the email address as a custom "email_address" node:
ProviderTypes.Basecamp => (string?) context.UserInfoResponse?["email_address"],
// Bitly returns one or more email addresses as a custom "emails" node:
ProviderTypes.Bitly => context.UserInfoResponse?["emails"]
?.GetUnnamedParameters()
?.Where(parameter => (bool?) parameter["is_primary"] is true)
?.Select(parameter => (string?) parameter["email"])
?.FirstOrDefault(),
// HubSpot returns the email address as a custom "user" node:
ProviderTypes.HubSpot => (string?) context.UserInfoResponse?["user"],
// Mailchimp returns the email address as a custom "login/login_email" node:
ProviderTypes.Mailchimp => (string?) context.UserInfoResponse?["login"]?["login_email"],
// Notion returns the email address as a custom "bot/owner/user/person/email" node
// but requires a special capability to access this node, that may not be present:
ProviderTypes.Notion => (string?) context.UserInfoResponse?["bot"]?["owner"]?["user"]?["person"]?["email"],
// Patreon returns the email address as a custom "attributes/email" node:
ProviderTypes.Patreon => (string?) context.UserInfoResponse?["attributes"]?["email"],
// ServiceChannel and Zoho return the email address as a custom "Email" node:
ProviderTypes.ServiceChannel or ProviderTypes.Zoho => (string?) context.UserInfoResponse?["Email"],
// Shopify returns the email address as a custom "associated_user/email" node in token responses:
ProviderTypes.Shopify => (string?) context.TokenResponse?["associated_user"]?["email"],
_ => context.MergedPrincipal.GetClaim(ClaimTypes.Email)
});
context.MergedPrincipal.SetClaim(ClaimTypes.Name, issuer: issuer, value: context.Registration.ProviderType switch
{
// These providers return the username as a custom "username" node:
ProviderTypes.ArcGisOnline or ProviderTypes.Dailymotion or ProviderTypes.DeviantArt or
ProviderTypes.Discord or ProviderTypes.Disqus or ProviderTypes.Kook or
ProviderTypes.Lichess or ProviderTypes.Mastodon or ProviderTypes.Mixcloud or
ProviderTypes.Trakt or ProviderTypes.WordPress
=> (string?) context.UserInfoResponse?["username"],
// Basecamp and Harvest don't return a username so one is created using the "first_name" and "last_name" nodes:
ProviderTypes.Basecamp or ProviderTypes.Harvest
when context.UserInfoResponse?.HasParameter("first_name") is true &&
context.UserInfoResponse?.HasParameter("last_name") is true
=> $"{(string?) context.UserInfoResponse?["first_name"]} {(string?) context.UserInfoResponse?["last_name"]}",
// FitBit returns the username as a custom "displayName" node:
ProviderTypes.Fitbit => (string?) context.UserInfoResponse?["displayName"],
// Huawei returns the username as a custom "display_name" in the backchannel identity token:
ProviderTypes.Huawei => context.BackchannelIdentityTokenPrincipal?.GetClaim("display_name"),
// HubSpot returns the username as a custom "user" node:
ProviderTypes.HubSpot => (string?) context.UserInfoResponse?["user"],
// Mailchimp returns the username as a custom "accountname" node:
ProviderTypes.Mailchimp => (string?) context.UserInfoResponse?["accountname"],
// Mailchimp returns the username as a custom "sub" node:
ProviderTypes.MusicBrainz => (string?) context.UserInfoResponse?["sub"],
// Nextcloud returns the username as a custom "displayname" or "display-name" node:
ProviderTypes.Nextcloud => (string?) context.UserInfoResponse?["displayname"] ??
(string?) context.UserInfoResponse?["display-name"],
// Notion returns the username as a custom "bot/owner/user/name" node but
// requires a special capability to access this node, that may not be present:
ProviderTypes.Notion => (string?) context.UserInfoResponse?["bot"]?["owner"]?["user"]?["name"],
// Patreon doesn't return a username and requires using the complete user name as the username:
ProviderTypes.Patreon => (string?) context.UserInfoResponse?["attributes"]?["full_name"],
// ServiceChannel returns the username as a custom "UserName" node:
ProviderTypes.ServiceChannel => (string?) context.UserInfoResponse?["UserName"],
// Shopify doesn't return a username so one is created using the "first_name" and "last_name" nodes:
ProviderTypes.Shopify
when context.TokenResponse?["associated_user"]?["first_name"] is not null &&
context.TokenResponse?["associated_user"]?["last_name"] is not null
=> $"{(string?) context.TokenResponse?["associated_user"]?["first_name"]} {(string?) context.TokenResponse?["associated_user"]?["last_name"]}",
// Smartsheet doesn't return a username so one is created using the "firstName" and "lastName" nodes:
ProviderTypes.Smartsheet
when context.UserInfoResponse?.HasParameter("firstName") is true &&
context.UserInfoResponse?.HasParameter("lastName") is true
=> $"{(string?) context.UserInfoResponse?["firstName"]} {(string?) context.UserInfoResponse?["lastName"]}",
// These providers return the username as a custom "display_name" node:
ProviderTypes.Spotify or ProviderTypes.StackExchange or ProviderTypes.Zoom
=> (string?) context.UserInfoResponse?["display_name"],
// Strava returns the username as a custom "athlete/username" node in token responses:
ProviderTypes.Strava => (string?) context.TokenResponse?["athlete"]?["username"],
// Streamlabs returns the username as a custom "streamlabs/display_name" node:
ProviderTypes.Streamlabs => (string?) context.UserInfoResponse?["streamlabs"]?["display_name"],
// Todoist returns the username as a custom "full_name" node:
ProviderTypes.Todoist => (string?) context.UserInfoResponse?["full_name"],
// Trovo returns the username as a custom "userName" node:
ProviderTypes.Trovo => (string?) context.UserInfoResponse?["userName"],
// Typeform returns the username as a custom "alias" node:
ProviderTypes.Typeform => (string?) context.UserInfoResponse?["alias"],
// Zoho returns the username as a custom "Display_Name" node:
ProviderTypes.Zoho => (string?) context.UserInfoResponse?["Display_Name"],
_ => context.MergedPrincipal.GetClaim(ClaimTypes.Name)
});
context.MergedPrincipal.SetClaim(ClaimTypes.NameIdentifier, issuer: issuer, value: context.Registration.ProviderType switch
{
// These providers return the user identifier as a custom "user_id" node:
ProviderTypes.Amazon or ProviderTypes.HubSpot or
ProviderTypes.StackExchange or ProviderTypes.Typeform
=> (string?) context.UserInfoResponse?["user_id"],
// ArcGIS and Trakt don't return a user identifier and require using the username as the identifier:
ProviderTypes.ArcGisOnline or ProviderTypes.Trakt
=> (string?) context.UserInfoResponse?["username"],
// Atlassian returns the user identifier as a custom "account_id" node:
ProviderTypes.Atlassian => (string?) context.UserInfoResponse?["account_id"],
// These providers return the user identifier as a custom "id" node:
ProviderTypes.Airtable or ProviderTypes.Basecamp or ProviderTypes.Box or
ProviderTypes.Dailymotion or ProviderTypes.Deezer or ProviderTypes.Discord or
ProviderTypes.Disqus or ProviderTypes.Facebook or ProviderTypes.GitCode or
ProviderTypes.Gitee or ProviderTypes.GitHub or ProviderTypes.Harvest or
ProviderTypes.Kook or ProviderTypes.Kroger or ProviderTypes.Lichess or
ProviderTypes.Mastodon or ProviderTypes.Meetup or ProviderTypes.Nextcloud or
ProviderTypes.Patreon or ProviderTypes.Pipedrive or ProviderTypes.Reddit or
ProviderTypes.Smartsheet or ProviderTypes.Spotify or ProviderTypes.SubscribeStar or
ProviderTypes.Todoist or ProviderTypes.Twitter or ProviderTypes.Weibo or
ProviderTypes.Zoom
=> (string?) context.UserInfoResponse?["id"],
// Bitbucket returns the user identifier as a custom "uuid" node:
ProviderTypes.Bitbucket => (string?) context.UserInfoResponse?["uuid"],
// Bitly returns the user identifier as a custom "login" node:
ProviderTypes.Bitly => (string?) context.UserInfoResponse?["login"],
// Calendly returns the user identifier (formatted as a URI) as a custom "uri" node:
ProviderTypes.Calendly => (string?) context.UserInfoResponse?["uri"],
// DeviantArt returns the user identifier as a custom "userid" node:
ProviderTypes.DeviantArt => (string?) context.UserInfoResponse?["userid"],
// Fitbit returns the user identifier as a custom "encodedId" node:
ProviderTypes.Fitbit => (string?) context.UserInfoResponse?["encodedId"],
// Mailchimp returns the user identifier as a custom "login/login_id" node:
ProviderTypes.Mailchimp => (string?) context.UserInfoResponse?["login"]?["login_id"],
// Mixcloud returns the user identifier as a custom "key" node:
ProviderTypes.Mixcloud => (string?) context.UserInfoResponse?["key"],
// MusicBrainz returns the user identifier as a custom "metabrainz_user_id" node:
ProviderTypes.MusicBrainz => (string?) context.UserInfoResponse?["metabrainz_user_id"],
// Notion returns the user identifier as a custom "bot/owner/user/id" node but
// requires a special capability to access this node, that may not be present:
ProviderTypes.Notion => (string?) context.UserInfoResponse?["bot"]?["owner"]?["user"]?["id"],
// ServiceChannel returns the user identifier as a custom "UserId" node:
ProviderTypes.ServiceChannel => (string?) context.UserInfoResponse?["UserId"],
// Shopify returns the user identifier as a custom "associated_user/id" node in token responses:
ProviderTypes.Shopify => (string?) context.TokenResponse?["associated_user"]?["id"],
// Strava returns the user identifier as a custom "athlete/id" node in token responses:
ProviderTypes.Strava => (string?) context.TokenResponse?["athlete"]?["id"],
// Stripe returns the user identifier as a custom "stripe_user_id" node in token responses:
ProviderTypes.StripeConnect => (string?) context.TokenResponse?["stripe_user_id"],
// Streamlabs returns the user identifier as a custom "streamlabs/id" node:
ProviderTypes.Streamlabs => (string?) context.UserInfoResponse?["streamlabs"]?["id"],
// Trovo returns the user identifier as a custom "userId" node:
ProviderTypes.Trovo => (string?) context.UserInfoResponse?["userId"],
// Tumblr doesn't return a user identifier and requires using the username as the identifier:
ProviderTypes.Tumblr => (string?) context.UserInfoResponse?["name"],
// Vimeo returns the user identifier as a custom "uri" node, prefixed with "/users/":
ProviderTypes.Vimeo => (string?) context.UserInfoResponse?["uri"] is string uri &&
uri.StartsWith("/users/", StringComparison.Ordinal) ? uri["/users/".Length..] : null,
// WordPress returns the user identifier as a custom "ID" node:
ProviderTypes.WordPress => (string?) context.UserInfoResponse?["ID"],
// WordPress returns the user identifier as a custom "ZUID" node:
ProviderTypes.Zoho => (string?) context.UserInfoResponse?["ZUID"],
_ => context.MergedPrincipal.GetClaim(ClaimTypes.NameIdentifier)
});
return default;
}
}

@t1moH1ch
Copy link
Contributor Author

t1moH1ch commented Feb 5, 2025

@kevinchalet, thanks for yor patience) and amazing OpenIddict library.
I`ve update a console application project with the test data for yandex and vk id providers

@kevinchalet
Copy link
Member

@kevinchalet, thanks for yor patience) and amazing OpenIddict library.

Haha, no problem. Thanks for your contribution!

I`ve update a console application project with the test data for yandex and vk id providers

Thanks. Unfortunately, I don't have a VK ID or Yandex account, so I won't be able to test it myself. Can you please post a screenshot of the claims table for each provider? (feel free to hide sensitive values)

Please also test the token refreshing scenario for both providers and token revocation for Yandex.

@kevinchalet
Copy link
Member

Note: don't worry about the git conflict, I'll take care of it when merging the PR.

@t1moH1ch
Copy link
Contributor Author

t1moH1ch commented Feb 6, 2025

Refresh token for Yandex
yandex-refresh-1
yandex-refresh-2

VK ID
vk-refresh-1
vk-refresh-2

Help me please with revoke token action. How can i check it? I check this issue, but this take no effect. TokenManager.RevokeByAuthorizationIdAsync returns 0 and i stay login.
revoke-yandex

@kevinchalet
Copy link
Member

Help me please with revoke token action.

You can test both token refreshing (your 2 screenshots are the initial authentication, not a token refreshing) and token revocation using the OpenIddict.Sandbox.Console.Client app. Let me know if you need additional information 👍🏻

@kevinchalet kevinchalet merged commit 03bd575 into openiddict:dev Feb 10, 2025
6 checks passed
@kevinchalet
Copy link
Member

Merged! Thanks a lot for your great contribution and the time you spent testing my commits 😁

With these 2 new providers, OpenIddict now supports (exactly) 100 web services! 🎉

@t1moH1ch
Copy link
Contributor Author

My big thanks to you for support 🥇

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants