Skip to content
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
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ public static partial class OpenIddictClientWebIntegrationHandlers
OverrideTokenEndpointClientAuthenticationMethod.Descriptor,
OverrideTokenEndpoint.Descriptor,
AttachNonStandardClientAssertionClaims.Descriptor,
OverrideScopes.Descriptor,
AttachAdditionalTokenRequestParameters.Descriptor,
AttachTokenRequestNonStandardClientCredentials.Descriptor,
AdjustRedirectUriInTokenRequest.Descriptor,
Expand Down Expand Up @@ -603,6 +604,40 @@ public ValueTask HandleAsync(ProcessAuthenticationContext context)
}
}

/// <summary>
/// Contains the logic responsible for overriding the scopes for the providers that require it.
/// </summary>
public sealed class OverrideScopes : IOpenIddictClientHandler<ProcessAuthenticationContext>
{
/// <summary>
/// Gets the default descriptor definition assigned to this handler.
/// </summary>
public static OpenIddictClientHandlerDescriptor Descriptor { get; }
= OpenIddictClientHandlerDescriptor.CreateBuilder<ProcessAuthenticationContext>()
.AddFilter<RequireTokenRequest>()
.UseSingletonHandler<OverrideScopes>()
.SetOrder(AttachTokenRequestParameters.Descriptor.Order - 500)
.SetType(OpenIddictClientHandlerType.BuiltIn)
.Build();

/// <inheritdoc />
public ValueTask HandleAsync(ProcessAuthenticationContext context)
{
ArgumentNullException.ThrowIfNull(context);

// osu! requires at least one scope to be set for client credentials grant, as tokens without
// scopes are not valid. If no scope is explicitly specified, use the default value "public".
if (context.GrantType is GrantTypes.ClientCredentials &&
context.Registration.ProviderType is ProviderTypes.Osu &&
context.Scopes.Count is 0)
{
context.Scopes.Add("public");
Copy link
Member

@kevinchalet kevinchalet Dec 15, 2025

Choose a reason for hiding this comment

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

Let's try something: try requesting one of the delegate, forum.write_manage and group_permissions scopes without adding public: if it works, it means public is not strictly required but that osu requires at least one scope to be set (what OpenIddict calls "default scope").

If it works without public being present, we may want to only add it if the context.Scopes collection is empty?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I don't have a Chat Bot account, so I cannot request delegate or other scopes that require delegation; my personal account receives the error 'Delegation with Client Credentials is only available to chat bots.' when requesting such permissions.

However, the documentation mentions: 'When using delegation, scopes that support delegation cannot be used together with scopes that do not support delegation. Delegation is only available to Chat Bots.'

This indicates that when a developer requests delegation scopes, appending public is incorrect, because the Scopes section points out that public is not marked as Can Delegate.

The source code of osu!web also confirms this:

https://github.com/ppy/osu-web/blob/c2c9404146a934bd985d40a11a5a96fda7261338/app/Models/OAuth/Token.php#L27
https://github.com/ppy/osu-web/blob/c2c9404146a934bd985d40a11a5a96fda7261338/app/Models/OAuth/Token.php#L236-L245

In a Client Credentials request, if the scopes contain elements from SCOPES_REQUIRE_DELEGATION, all elements must be included in the SCOPES_REQUIRE_DELEGATION.

Therefore, I agree with your suggestion to add public to the context.Scopes only when it is empty.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I need to create an issue on the osu!web later regarding the documentation.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

}

return ValueTask.CompletedTask;
}
}

/// <summary>
/// Contains the logic responsible for attaching additional parameters
/// to the token request for the providers that require it.
Expand Down Expand Up @@ -887,7 +922,7 @@ public ValueTask HandleAsync(ProcessAuthenticationContext context)
// Note: these providers don't have a static userinfo endpoint attached to their configuration
// so OpenIddict doesn't, by default, send a userinfo request. Since a dynamic endpoint is later
// computed and attached to the context, the default value MUST be overridden to send a request.
ProviderTypes.Dailymotion or ProviderTypes.HubSpot or
ProviderTypes.Dailymotion or ProviderTypes.HubSpot or ProviderTypes.Osu or
ProviderTypes.SuperOffice or ProviderTypes.Zoho
when context.GrantType is GrantTypes.AuthorizationCode or GrantTypes.DeviceCode or
GrantTypes.Implicit or GrantTypes.Password or
Expand Down Expand Up @@ -1017,6 +1052,16 @@ ProviderTypes.HubSpot when
left : new Uri("https://api.hubapi.com/oauth/v1/access-tokens", UriKind.Absolute),
right: new Uri(token, UriKind.Relative)),

// osu! supports specifying a game mode when querying the userinfo endpoint.
ProviderTypes.Osu => context.Properties.GetValueOrDefault(Osu.Properties.GameMode) switch
{
{ Length: > 0 } mode => OpenIddictHelpers.CreateAbsoluteUri(
left : new Uri("https://osu.ppy.sh/api/v2/me", UriKind.Absolute),
right: new Uri(mode, UriKind.Relative)),

_ => new Uri("https://osu.ppy.sh/api/v2/me", UriKind.Absolute)
},

// SuperOffice doesn't expose a static OpenID Connect userinfo endpoint but offers an API whose
// absolute URI needs to be computed based on a special claim returned in the identity token.
ProviderTypes.SuperOffice when
Expand Down Expand Up @@ -1387,7 +1432,7 @@ public ValueTask HandleAsync(ProcessAuthenticationContext context)
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
ProviderTypes.Osu or ProviderTypes.Trakt or ProviderTypes.WordPress
=> (string?) context.UserInfoResponse?["username"],

// These providers don't return a username so one is created using the "first_name" and "last_name" nodes:
Expand Down Expand Up @@ -1482,17 +1527,18 @@ 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.Figma or
ProviderTypes.Genesys 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
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.Figma or
ProviderTypes.Genesys 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.Osu 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"],

// Bitbucket returns the user identifier as a custom "uuid" node:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1747,6 +1747,39 @@
ConfigurationEndpoint="https://api.orange.com/openidconnect/fr/v1/.well-known/openid-configuration" />
</Provider>

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

<Provider Name="Osu" DisplayName="osu!" Id="924114e6-6334-4124-bbf9-46cd5458da25"
Documentation="https://osu.ppy.sh/docs/index.html#authentication">
<Environment Issuer="https://osu.ppy.sh/">
<!--
Note: the address of the userinfo endpoint is not set here but is dynamically computed based
on the game mode selected when triggering the authentication challenge. If no game mode was
explicitly set, the default endpoint (https://osu.ppy.sh/api/v2/me) is automatically used.
-->

<Configuration AuthorizationEndpoint="https://osu.ppy.sh/oauth/authorize"
TokenEndpoint="https://osu.ppy.sh/oauth/token">
<GrantType Value="authorization_code" />
<GrantType Value="client_credentials" />
<GrantType Value="refresh_token" />
</Configuration>
</Environment>

<Constant Class="GameModes" Value="osu" Name="Standard" />
<Constant Class="GameModes" Value="taiko" Name="Taiko" />
<Constant Class="GameModes" Value="fruits" Name="Catch" />
<Constant Class="GameModes" Value="mania" Name="Mania" />

<Property Name="GameMode" DictionaryKey=".game_mode" />
</Provider>

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