Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,11 @@
*/

using System.Collections.Immutable;
using System.Diagnostics;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using OpenIddict.Client.SystemNetHttp;
using static OpenIddict.Client.SystemNetHttp.OpenIddictClientSystemNetHttpHandlerFilters;
using static OpenIddict.Client.SystemNetHttp.OpenIddictClientSystemNetHttpHandlers;
using static OpenIddict.Client.WebIntegration.OpenIddictClientWebIntegrationConstants;
Expand All @@ -25,12 +28,64 @@ public static class Revocation
MapNonStandardRequestParameters.Descriptor,
OverrideHttpMethod.Descriptor,
AttachBearerAccessToken.Descriptor,
AttachNonStandardRequestPayload.Descriptor,

/*
* Revocation response extraction:
*/
NormalizeContentType.Descriptor
];

/// <summary>
/// Contains the logic responsible for attaching a non-standard payload for the providers that require it.
/// </summary>
public sealed class AttachNonStandardRequestPayload : IOpenIddictClientHandler<PrepareRevocationRequestContext>
{
/// <summary>
/// Gets the default descriptor definition assigned to this handler.
/// </summary>
public static OpenIddictClientHandlerDescriptor Descriptor { get; }
= OpenIddictClientHandlerDescriptor.CreateBuilder<PrepareRevocationRequestContext>()
.AddFilter<RequireHttpUri>()
.UseSingletonHandler<AttachNonStandardRequestPayload>()
.SetOrder(AttachHttpParameters<PrepareRevocationRequestContext>.Descriptor.Order + 500)
.SetType(OpenIddictClientHandlerType.BuiltIn)
.Build();

/// <inheritdoc/>
public ValueTask HandleAsync(PrepareRevocationRequestContext context)
{
if (context is null)
{
throw new ArgumentNullException(nameof(context));
}

Debug.Assert(context.Transaction.Request is not null, SR.GetResourceString(SR.ID4008));

// This handler only applies to System.Net.Http requests. If the HTTP request cannot be resolved,
// this may indicate that the request was incorrectly processed by another client stack.
var request = context.Transaction.GetHttpRequestMessage() ??
throw new InvalidOperationException(SR.GetResourceString(SR.ID0173));

request.Content = context.Registration.ProviderType switch
{
// The token revocation endpoints exposed by these providers
// requires sending the request parameters as a JSON payload:
ProviderTypes.Miro => JsonContent.Create(
context.Transaction.Request,
OpenIddictSerializer.Default.Request,
new MediaTypeHeaderValue(OpenIddictClientSystemNetHttpConstants.MediaTypes.Json)
{
CharSet = OpenIddictClientSystemNetHttpConstants.Charsets.Utf8
}),

_ => request.Content
};

return default;
}
}


/// <summary>
/// Contains the logic responsible for mapping non-standard request parameters
Expand All @@ -56,13 +111,37 @@ public ValueTask HandleAsync(PrepareRevocationRequestContext context)
throw new ArgumentNullException(nameof(context));
}

// Weibo, VK ID and Yandex don't support the standard "token" parameter and
// These providers don't support the standard "token" parameter and
// require using the non-standard "access_token" parameter instead.
if (context.Registration.ProviderType is ProviderTypes.Weibo or ProviderTypes.VkId or ProviderTypes.Yandex)
if (context.Registration.ProviderType is
ProviderTypes.VkId or ProviderTypes.Webflow or
ProviderTypes.Weibo or ProviderTypes.Yandex)
{
context.Request.AccessToken = context.Token;
context.Request.Token = null;
context.Request.TokenTypeHint = null;
}

// Linear requires only the access_token and no other parameters.
else if (context.Registration.ProviderType is ProviderTypes.Linear)
{
context.Request.AccessToken = context.Token;
context.Request.Token = null;
context.Request.TokenTypeHint = null;
context.Request.ClientId = null;
}

// Miro uses a JSON payload that expects the non-standard
// "accessToken", "clientId" and "clientSecret" properties.
else if (context.Registration.ProviderType is ProviderTypes.Miro)
{
context.Request["accessToken"] = context.Token;
Copy link
Member

@kevinchalet kevinchalet Mar 26, 2025

Choose a reason for hiding this comment

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

Since you're going to send the access token in the Authorization header, there's no real point mapping it here. And if you don't nullify context.Request.Token here, you can reuse the same branch as the Zendesk provider in the AttachBearerAccessToken handler 😃

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Miro requires both the accessToken property in the JSON payload and the Bearer token. If I send just the Bearer token without the accessToken property, I get an error:

image

Likewise, if I omit the Bearer token and send just the accessToken property, it fails as well:

image

I need to supply both to get a successful response:

image

Copy link
Member

Choose a reason for hiding this comment

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

Ah yeah, I thought that branch was for the Linear provider... that uses a single access_token parameter 🤣

context.Request["clientId"] = context.Request.ClientId;
Copy link
Member

Choose a reason for hiding this comment

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

Interesting, I don't see these parameters mentioned in https://developers.linear.app/docs/oauth/authentication#id-6.-revoke-an-access-token. Are we sure their non-standard revocation endpoint supports client authentication?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yeah, Linear is an interesting one. Before I updated the parameters according to their docs, I tested the revocation and it worked:

image

So, in the initial PR, I did not make the code changes according to their docs. But, it is clearly risky doing this as it is not documented to work this way, and they can easily change it in the future so it does not work correctly anymore.

I have now updated the code to conform to their docs:

image

context.Request["clientSecret"] = context.Request.ClientSecret;
context.Request.Token = null;
context.Request.TokenTypeHint = null;
context.Request.ClientId = null;
context.Request.ClientSecret = null;
}

return default;
Expand Down Expand Up @@ -148,6 +227,15 @@ public ValueTask HandleAsync(PrepareRevocationRequestContext context)
context.Request.Token = null;
}

// Miro requires using bearer authentication with the token that is going to be revoked.
//
// Note: the token property CANNOT be used here as the token parameter is mapped to "accessToken".
else if (context.Registration.ProviderType is ProviderTypes.Miro &&
(string?) context.Request["accessToken"] is { Length: > 0 } token)
{
request.Headers.Authorization = new AuthenticationHeaderValue(Schemes.Bearer, token);
}

return default;
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ public ValueTask HandleAsync(PrepareUserInfoRequestContext context)
{
// The userinfo endpoints exposed by these providers
// are based on GraphQL, which requires using POST:
ProviderTypes.Meetup or ProviderTypes.SubscribeStar => HttpMethod.Post,
ProviderTypes.Linear or ProviderTypes.Meetup or ProviderTypes.SubscribeStar => HttpMethod.Post,

// The userinfo endpoints exposed by these providers
// use custom protocols that require using POST:
Expand Down Expand Up @@ -282,7 +282,7 @@ public ValueTask HandleAsync(PrepareUserInfoRequestContext context)
{
// The userinfo endpoints exposed by these providers are based on GraphQL,
// which requires sending the request parameters as a JSON payload:
ProviderTypes.Meetup or ProviderTypes.SubscribeStar => JsonContent.Create(
ProviderTypes.Linear or ProviderTypes.Meetup or ProviderTypes.SubscribeStar => JsonContent.Create(
context.Transaction.Request,
OpenIddictSerializer.Default.Request,
new MediaTypeHeaderValue(MediaTypes.Json)
Expand Down Expand Up @@ -433,10 +433,22 @@ ProviderTypes.Patreon or ProviderTypes.Pipedrive or ProviderTypes.Twitter
=> new(context.Response["data"]?.GetNamedParameters() ??
throw new InvalidOperationException(SR.FormatID0334("data"))),

// Linear returns a nested "viewer" object that is itself nested in a GraphQL "data" node.
ProviderTypes.Linear => new(context.Response["data"]?["viewer"]?.GetNamedParameters() ??
throw new InvalidOperationException(SR.FormatID0334("data/viewer"))),

// Meetup returns a nested "self" object that is itself nested in a GraphQL "data" node.
ProviderTypes.Meetup => new(context.Response["data"]?["self"]?.GetNamedParameters() ??
throw new InvalidOperationException(SR.FormatID0334("data/self"))),

// Miro returns a nested "user" object, as well as a nested "team" and "organization".
ProviderTypes.Miro => new(context.Response["user"]?.GetNamedParameters() ??
throw new InvalidOperationException(SR.FormatID0334("user")))
{
["organization"] = context.Response["organization"],
["team"] = context.Response["team"]
},

// Nextcloud returns a nested "data" object that is itself nested in a "ocs" node.
ProviderTypes.Nextcloud => new(context.Response["ocs"]?["data"]?.GetNamedParameters() ??
throw new InvalidOperationException(SR.FormatID0334("ocs/data"))),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1147,6 +1147,15 @@ public ValueTask HandleAsync(ProcessAuthenticationContext context)

context.UserInfoRequest["fields"] = string.Join(",", settings.Fields);
}

// Linear's userinfo endpoint is a GraphQL implementation that requires
// sending a proper "query" parameter containing the requested user details.
else if (context.Registration.ProviderType is ProviderTypes.Linear)
{
var settings = context.Registration.GetLinearSettings();

context.UserInfoRequest["query"] = $"query {{ viewer {{ {string.Join(" ", settings.UserFields)} }} }}";
}

// Meetup's userinfo endpoint is a GraphQL implementation that requires
// sending a proper "query" parameter containing the requested user details.
Expand Down Expand Up @@ -1504,15 +1513,16 @@ ProviderTypes.ArcGisOnline or ProviderTypes.Trakt
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.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.Yandex or
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.Gitee or
ProviderTypes.GitHub or ProviderTypes.Harvest or ProviderTypes.Kook or
ProviderTypes.Kroger or ProviderTypes.Lichess or ProviderTypes.Linear or
ProviderTypes.Mastodon or ProviderTypes.Meetup or ProviderTypes.Miro 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.Webflow or ProviderTypes.Weibo or ProviderTypes.Yandex or
ProviderTypes.Zoom
=> (string?) context.UserInfoResponse?["id"],

Expand Down Expand Up @@ -1918,6 +1928,15 @@ public ValueTask HandleAsync(ProcessChallengeContext context)
context.Request.Display = settings.Display;
}

// Linear allows setting the prompt parameter (setting it to "consent" will
// force the consent screen to be displayed for each authorization request).
else if (context.Registration.ProviderType is ProviderTypes.Linear)
{
var settings = context.Registration.GetLinearSettings();

context.Request.Prompt = settings.Prompt;
}

// By default, MusicBrainz doesn't return a refresh token but allows sending an "access_type"
// parameter to retrieve one (but it is only returned during the first authorization dance).
else if (context.Registration.ProviderType is ProviderTypes.MusicBrainz)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1069,6 +1069,35 @@
</Environment>
</Provider>

<!--
▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
██ ████▄ ▄██ ▀██ ██ ▄▄▄█ ▄▄▀██ ▄▄▀██
██ █████ ███ █ █ ██ ▄▄▄█ ▀▀ ██ ▀▀▄██
██ ▀▀ █▀ ▀██ ██▄ ██ ▀▀▀█ ██ ██ ██ ██
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
-->
<Provider Name="Linear" Id="9d5f20c2-1b3d-4375-8eb0-c6fcef63c3f7"
Documentation="https://developers.linear.app/docs/oauth/authentication">
<Environment Issuer="https://linear.app/">
<Configuration AuthorizationEndpoint="https://linear.app/oauth/authorize"
RevocationEndpoint="https://api.linear.app/oauth/revoke"
TokenEndpoint="https://api.linear.app/oauth/token"
UserInfoEndpoint="https://api.linear.app/graphql">
<RevocationEndpointAuthMethod Value="none"/>
</Configuration>
</Environment>

<Setting PropertyName="UserFields" ParameterName="fields" Collection="true" Type="String"
Description="The list of user fields to expand from the GraphQL endpoint (by default, only basic fields are requested)">
<Item Value="email" Default="true" Required="false" />
<Item Value="id" Default="true" Required="false" />
<Item Value="name" Default="true" Required="false" />
</Setting>

<Setting PropertyName="Prompt" ParameterName="prompt" Type="String" Required="false"
Description="The value used as the 'prompt' parameter (can be set to 'consent' to display the consent form for each authorization demand)" />
</Provider>

<!--
▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
██ ████▄ ▄██ ▀██ ██ █▀▄██ ▄▄▄██ ▄▄▀█▄ ▄██ ▀██ ██
Expand Down Expand Up @@ -1198,6 +1227,24 @@
Description="The tenant used to identify the Microsoft Entra instance (by default, the common tenant is used)" />
</Provider>

<!--
▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
██ ▄▀▄ █▄ ▄██ ▄▄▀██ ▄▄▄ ██
██ █ █ ██ ███ ▀▀▄██ ███ ██
██ ███ █▀ ▀██ ██ ██ ▀▀▀ ██
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
-->

<Provider Name="Miro" Id="4e9426b3-7fd5-480a-a89e-67a80cfc5622"
Documentation="https://developers.miro.com/docs/getting-started-with-oauth">
<Environment Issuer="https://miro.com/">
<Configuration AuthorizationEndpoint="https://miro.com/oauth/authorize"
RevocationEndpoint="https://api.miro.com/v2/oauth/revoke"
TokenEndpoint="https://api.miro.com/v1/oauth/token"
UserInfoEndpoint="https://api.miro.com/v1/oauth-token" />
</Environment>
</Provider>

<!--
▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
██ ▄▀▄ █▄ ▄█▄▀█▀▄██ ▄▄▀██ █████ ▄▄▄ ██ ██ ██ ▄▄▀██
Expand Down Expand Up @@ -2137,6 +2184,29 @@
<Environment Issuer="https://www.webex.com/" ConfigurationEndpoint="https://webexapis.com/v1/.well-known/openid-configuration" />
</Provider>

<!--
▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
██ ███ ██ ▄▄▄██ ▄▄▀██ ▄▄▄██ █████ ▄▄▄ ██ ███ ██
██ █ █ ██ ▄▄▄██ ▄▄▀██ ▄▄███ █████ ███ ██ █ █ ██
██▄▀▄▀▄██ ▀▀▀██ ▀▀ ██ █████ ▀▀ ██ ▀▀▀ ██▄▀▄▀▄██
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
-->

<Provider Name="Webflow" Id="87ec6fc6-771c-46a9-a8e9-f0049927536e"
Documentation="https://developers.webflow.com/v2.0.0/data/reference/oauth-app">
<Environment Issuer="https://webflow.com/">
<Configuration AuthorizationEndpoint="https://webflow.com/oauth/authorize"
RevocationEndpoint="https://webflow.com/oauth/revoke_authorization"
TokenEndpoint="https://api.webflow.com/oauth/access_token"
UserInfoEndpoint="https://api.webflow.com/v2/token/authorized_by" />
<!--
Note: Webflow requires sending the "authorized_user:read" scope to be able to use the userinfo endpoint.
-->

<Scope Name="authorized_user:read" Default="true" Required="true" />
</Environment>
</Provider>

<!--
▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
██ ███ ██ ▄▄▄█▄ ▄██ ▄▄▀██ ▄▄▄ ██
Expand Down