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

More OAuth2 extensions #61

Merged
merged 2 commits into from
Dec 30, 2023
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
1 change: 1 addition & 0 deletions MetaBrainz.MusicBrainz.sln.DotSettings
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@
<s:Boolean x:Key="/Default/UserDictionary/Words/=mixtape/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=musicbrainz/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=nnnn/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=PKCE/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=placeaccent/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=pregap/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=primarytype/@EntryIndexedValue">True</s:Boolean>
Expand Down
4 changes: 2 additions & 2 deletions MetaBrainz.MusicBrainz/AuthorizationScope.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,11 @@ public enum AuthorizationScope {
/// <summary>Request all available permissions (not recommended).</summary>
Everything = -1,

/// <summary>View the user's public profile information (username, age, country, homepage).</summary>
/// <summary>View the user's public profile information (e.g. username and time zone).</summary>
Profile = 1 << 0,

/// <summary>View the user's email address.</summary>
EMail = 1 << 1,
Email = 1 << 1,

/// <summary>View and modify the user's private tags.</summary>
Tag = 1 << 2,
Expand Down
8 changes: 5 additions & 3 deletions MetaBrainz.MusicBrainz/Interfaces/IUserInfo.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,13 @@

namespace MetaBrainz.MusicBrainz.Interfaces;

/// <summary>Information about a user as returned by OAuth2.</summary>
/// <summary>
/// Information about a user as returned by OAuth2 (if <see cref="AuthorizationScope.Profile"/> has been granted).
/// </summary>
public interface IUserInfo : IJsonBasedObject {

/// <summary>The user's email address.</summary>
string Email { get; }
/// <summary>The user's email address. Will only be provided if <see cref="AuthorizationScope.Email"/> has been granted.</summary>
string? Email { get; init; }

/// <summary>The user's gender.</summary>
string? Gender { get; }
Expand Down
6 changes: 2 additions & 4 deletions MetaBrainz.MusicBrainz/Json/Readers/UserInfoReader.cs
Original file line number Diff line number Diff line change
Expand Up @@ -68,13 +68,11 @@ protected override UserInfo ReadObjectContents(ref Utf8JsonReader reader, JsonSe
if (name is null) {
throw new JsonException("Expected user name not found or null.");
}
if (email is null) {
throw new JsonException("Expected email address not found or null.");
}
if (profile is null) {
throw new JsonException("Expected profile URI not found or null.");
}
return new UserInfo(id.Value, name, email, profile) {
return new UserInfo(id.Value, name, profile) {
Email = email,
Gender = gender,
TimeZone = timeZone,
UnhandledProperties = rest,
Expand Down
80 changes: 59 additions & 21 deletions MetaBrainz.MusicBrainz/OAuth2.cs
Original file line number Diff line number Diff line change
Expand Up @@ -179,23 +179,34 @@ public string UrlScheme {
/// The URI that should receive the authorization code; use <see cref="OutOfBandUri"/> for out-of-band requests.
/// </param>
/// <param name="scope">The authorization scopes that should be included in the authorization code.</param>
/// <param name="state">An optional string that will be included in the response sent to <paramref name="redirectUri"/>.</param>
/// <param name="state">
/// Any string the application wants passed back after authorization; it will be included in the response sent to
/// <paramref name="redirectUri"/>. For example, this can be a CSRF token from your application. This parameter is optional, but
/// strongly recommended.
/// </param>
/// <param name="challenge">
/// MusicBrainz supports the use of "Proof Key for Code Exchange" (PKCE) by clients. This is strongly recommended to avoid
/// authorization code interception attacks.<br/>
/// See <seealso href="https://tools.ietf.org/html/rfc7636#section-4.1">RFC 7636</seealso> for the process of generating a
/// <c>code_verifier</c> and then a <c>code_challenge</c> (passed here) based on that.
/// </param>
/// <param name="challengeMethod">Either "S256" (recommended) or "plain" (the default if not specified).</param>
/// <param name="offlineAccess">Requests offline use (a refresh token will be provided alongside the access token).</param>
/// <param name="forcePrompt">
/// If <see langword="true"/>, the user will be required to confirm authorization even if the requested scopes have already been
/// granted.
/// </param>
/// <returns>The generated URI.</returns>
public Uri CreateAuthorizationRequest(Uri redirectUri, AuthorizationScope scope, string? state = null, bool offlineAccess = false,
bool forcePrompt = false) {
public Uri CreateAuthorizationRequest(Uri redirectUri, AuthorizationScope scope, string? state = null, string? challenge = null,
string? challengeMethod = null, bool offlineAccess = false, bool forcePrompt = false) {
if (scope == AuthorizationScope.None) {
throw new ArgumentException("At least one authorization scope must be selected.", nameof(scope));
}
var query = new StringBuilder();
query.Append("?response_type=code");
query.Append("&client_id=").Append(Uri.EscapeDataString(this.ClientId));
query.Append("&redirect_uri=").Append(Uri.EscapeDataString(redirectUri.ToString()));
query.Append("&scope=").Append(string.Join("+", OAuth2.ScopeStrings(scope)));
query.Append("&scope=").Append(string.Join('+', OAuth2.ScopeStrings(scope)));
if (state is not null) {
query.Append("&state=").Append(Uri.EscapeDataString(state));
}
Expand All @@ -205,6 +216,12 @@ public Uri CreateAuthorizationRequest(Uri redirectUri, AuthorizationScope scope,
if (forcePrompt) {
query.Append("&approval_prompt=force");
}
if (challenge is not null) {
query.Append("&code_challenge=").Append(Uri.EscapeDataString(challenge));
if (challengeMethod is not null) {
query.Append("&code_challenge_method=").Append(Uri.EscapeDataString(challengeMethod));
}
}
return new UriBuilder(this.UrlScheme, this.Server, this.Port, OAuth2.AuthorizationEndPoint, query.ToString()).Uri;
}

Expand All @@ -215,9 +232,14 @@ public Uri CreateAuthorizationRequest(Uri redirectUri, AuthorizationScope scope,
/// The URI to redirect to (or <see cref="OutOfBandUri"/> for out-of-band requests); must match the request URI used to obtain
/// <paramref name="code"/>.
/// </param>
/// <param name="verifier">
/// If you're using PKCE, pass the <c>code_verifier</c> here. The request will be rejected if it doesn't agree with the challenge
/// and challenge method used for the authorization request as generated via <see cref="CreateAuthorizationRequest"/>. The process
/// is described in detail by <seealso href="https://tools.ietf.org/html/rfc7636#section-4.5">RFC 7636</seealso>.
/// </param>
/// <returns>The obtained bearer token.</returns>
public IAuthorizationToken GetBearerToken(string code, string clientSecret, Uri redirectUri)
=> AsyncUtils.ResultOf(this.GetBearerTokenAsync(code, clientSecret, redirectUri));
public IAuthorizationToken GetBearerToken(string code, string clientSecret, Uri redirectUri, string? verifier = null)
=> AsyncUtils.ResultOf(this.GetBearerTokenAsync(code, clientSecret, redirectUri, verifier));

/// <summary>Exchanges an authorization code for a bearer token.</summary>
/// <param name="code">The authorization code to be used. If the request succeeds, this code will be invalidated.</param>
Expand All @@ -226,21 +248,34 @@ public IAuthorizationToken GetBearerToken(string code, string clientSecret, Uri
/// The URI to redirect to (or <see cref="OutOfBandUri"/> for out-of-band requests); must match the request URI used to obtain
/// <paramref name="code"/>.
/// </param>
/// <param name="verifier">
/// If you're using PKCE, pass the <c>code_verifier</c> here. The request will be rejected if it doesn't agree with the challenge
/// and challenge method used for the authorization request as generated via <see cref="CreateAuthorizationRequest"/>. The process
/// is described in detail by <seealso href="https://tools.ietf.org/html/rfc7636#section-4.5">RFC 7636</seealso>.
/// </param>
/// <param name="cancellationToken">The cancellation token to cancel the operation.</param>
/// <returns>The obtained bearer token.</returns>
public Task<IAuthorizationToken> GetBearerTokenAsync(string code, string clientSecret, Uri redirectUri,
public Task<IAuthorizationToken> GetBearerTokenAsync(string code, string clientSecret, Uri redirectUri, string? verifier = null,
CancellationToken cancellationToken = default)
=> this.RequestTokenAsync("bearer", code, clientSecret, redirectUri, cancellationToken);
=> this.RequestTokenAsync("bearer", code, clientSecret, redirectUri, verifier, cancellationToken);

/// <summary>Gets information about the user associated with an access token.</summary>
/// <param name="token">The access token.</param>
/// <returns>Information about the user associated with the access token.</returns>
/// <remarks>
/// If the <see cref="AuthorizationScope.Profile"/> permission has not been granted, this request will fail. In addition, it will
/// only include the user's email address if the <see cref="AuthorizationScope.Email"/> permission has been granted.
/// </remarks>
public IUserInfo GetUserInfo(string token) => AsyncUtils.ResultOf(this.GetUserInfoAsync(token));

/// <summary>Gets information about the user associated with an access token.</summary>
/// <param name="token">The access token.</param>
/// <param name="cancellationToken">The cancellation token to cancel the operation.</param>
/// <returns>Information about the user associated with the access token.</returns>
/// <remarks>
/// If the <see cref="AuthorizationScope.Profile"/> permission has not been granted, this request will fail. In addition, it will
/// only include the user's email address if the <see cref="AuthorizationScope.Email"/> permission has been granted.
/// </remarks>
public Task<IUserInfo> GetUserInfoAsync(string token, CancellationToken cancellationToken = default)
=> this.GetAsync<IUserInfo, UserInfo>(OAuth2.UserInfoEndPoint, token, cancellationToken);

Expand Down Expand Up @@ -456,8 +491,8 @@ private async Task<T> PostAsync<T>(string endPoint, HttpContent? content, Cancel
private async Task PostRevocationRequestAsync(string token, string clientSecret, CancellationToken cancellationToken) {
var body = new StringBuilder();
body.Append("token=").Append(Uri.EscapeDataString(token));
body.Append("&client_id=").Append(Uri.EscapeDataString(this.ClientId));
body.Append("&client_secret=").Append(Uri.EscapeDataString(clientSecret));
body.Append("&\nclient_id=").Append(Uri.EscapeDataString(this.ClientId));
body.Append("&\nclient_secret=").Append(Uri.EscapeDataString(clientSecret));
var content = new StringContent(body.ToString(), Encoding.UTF8, OAuth2.TokenRequestBodyType);
await this.PerformRequestAsync(HttpMethod.Post, OAuth2.RevokeEndPoint, content, null, cancellationToken).ConfigureAwait(false);
}
Expand All @@ -475,30 +510,33 @@ private Task<IAuthorizationToken> RefreshTokenAsync(string type, string codeOrTo
CancellationToken cancellationToken) {
var body = new StringBuilder();
body.Append("client_id=").Append(Uri.EscapeDataString(this.ClientId));
body.Append("&client_secret=").Append(Uri.EscapeDataString(clientSecret));
body.Append("&token_type=").Append(Uri.EscapeDataString(type));
body.Append("&grant_type=refresh_token");
body.Append("&refresh_token=").Append(Uri.EscapeDataString(codeOrToken));
body.Append("&\nclient_secret=").Append(Uri.EscapeDataString(clientSecret));
body.Append("&\ntoken_type=").Append(Uri.EscapeDataString(type));
body.Append("&\ngrant_type=refresh_token");
body.Append("&\nrefresh_token=").Append(Uri.EscapeDataString(codeOrToken));
return this.PostTokenRequestAsync(type, body.ToString(), cancellationToken);
}

private Task<IAuthorizationToken> RequestTokenAsync(string type, string codeOrToken, string clientSecret, Uri redirectUri,
CancellationToken cancellationToken) {
string? verifier, CancellationToken cancellationToken) {
var body = new StringBuilder();
body.Append("client_id=").Append(Uri.EscapeDataString(this.ClientId));
body.Append("&client_secret=").Append(Uri.EscapeDataString(clientSecret));
body.Append("&token_type=").Append(Uri.EscapeDataString(type));
body.Append("&grant_type=authorization_code");
body.Append("&code=").Append(Uri.EscapeDataString(codeOrToken));
body.Append("&redirect_uri=").Append(Uri.EscapeDataString(redirectUri.ToString()));
body.Append("&\nclient_secret=").Append(Uri.EscapeDataString(clientSecret));
body.Append("&\ntoken_type=").Append(Uri.EscapeDataString(type));
body.Append("&\ngrant_type=authorization_code");
body.Append("&\ncode=").Append(Uri.EscapeDataString(codeOrToken));
body.Append("&\nredirect_uri=").Append(Uri.EscapeDataString(redirectUri.ToString()));
if (verifier is not null) {
body.Append("&\ncode_verifier=").Append(Uri.EscapeDataString(verifier));
}
return this.PostTokenRequestAsync(type, body.ToString(), cancellationToken);
}

private static IEnumerable<string> ScopeStrings(AuthorizationScope scope) {
if ((scope & AuthorizationScope.Collection) != 0) {
yield return "collection";
}
if ((scope & AuthorizationScope.EMail) != 0) {
if ((scope & AuthorizationScope.Email) != 0) {
yield return "email";
}
if ((scope & AuthorizationScope.Profile) != 0) {
Expand Down
5 changes: 2 additions & 3 deletions MetaBrainz.MusicBrainz/Objects/UserInfo.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,13 @@ namespace MetaBrainz.MusicBrainz.Objects;

internal sealed class UserInfo : JsonBasedObject, IUserInfo {

public UserInfo(int userId, string name, string email, Uri profile) {
this.Email = email;
public UserInfo(int userId, string name, Uri profile) {
this.Name = name;
this.Profile = profile;
this.UserId = userId;
}

public string Email { get; }
public string? Email { get; init; }

public string? Gender { get; init; }

Expand Down
11 changes: 6 additions & 5 deletions public-api/MetaBrainz.MusicBrainz.net6.0.cs.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
public enum AuthorizationScope {

Collection = 16,
EMail = 2,
Email = 2,
Everything = -1,
None = 0,
Profile = 1,
Expand Down Expand Up @@ -167,15 +167,15 @@ public sealed class OAuth2 : System.IDisposable {

public void ConfigureClientCreation(System.Func<System.Net.Http.HttpClient>? code);

public System.Uri CreateAuthorizationRequest(System.Uri redirectUri, AuthorizationScope scope, string? state = null, bool offlineAccess = false, bool forcePrompt = false);
public System.Uri CreateAuthorizationRequest(System.Uri redirectUri, AuthorizationScope scope, string? state = null, string? challenge = null, string? challengeMethod = null, bool offlineAccess = false, bool forcePrompt = false);

public sealed override void Dispose();

protected override void Finalize();

public MetaBrainz.MusicBrainz.Interfaces.IAuthorizationToken GetBearerToken(string code, string clientSecret, System.Uri redirectUri);
public MetaBrainz.MusicBrainz.Interfaces.IAuthorizationToken GetBearerToken(string code, string clientSecret, System.Uri redirectUri, string? verifier = null);

public System.Threading.Tasks.Task<MetaBrainz.MusicBrainz.Interfaces.IAuthorizationToken> GetBearerTokenAsync(string code, string clientSecret, System.Uri redirectUri, System.Threading.CancellationToken cancellationToken = default);
public System.Threading.Tasks.Task<MetaBrainz.MusicBrainz.Interfaces.IAuthorizationToken> GetBearerTokenAsync(string code, string clientSecret, System.Uri redirectUri, string? verifier = null, System.Threading.CancellationToken cancellationToken = default);

public MetaBrainz.MusicBrainz.Interfaces.IUserInfo GetUserInfo(string token);

Expand Down Expand Up @@ -1892,8 +1892,9 @@ public interface IStreamingQueryResults<out TItem> : System.Collections.Generic.
```cs
public interface IUserInfo : MetaBrainz.Common.Json.IJsonBasedObject {

string Email {
string? Email {
public abstract get;
public abstract init;
}

string? Gender {
Expand Down
11 changes: 6 additions & 5 deletions public-api/MetaBrainz.MusicBrainz.net8.0.cs.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
public enum AuthorizationScope {

Collection = 16,
EMail = 2,
Email = 2,
Everything = -1,
None = 0,
Profile = 1,
Expand Down Expand Up @@ -167,15 +167,15 @@ public sealed class OAuth2 : System.IDisposable {

public void ConfigureClientCreation(System.Func<System.Net.Http.HttpClient>? code);

public System.Uri CreateAuthorizationRequest(System.Uri redirectUri, AuthorizationScope scope, string? state = null, bool offlineAccess = false, bool forcePrompt = false);
public System.Uri CreateAuthorizationRequest(System.Uri redirectUri, AuthorizationScope scope, string? state = null, string? challenge = null, string? challengeMethod = null, bool offlineAccess = false, bool forcePrompt = false);

public sealed override void Dispose();

protected override void Finalize();

public MetaBrainz.MusicBrainz.Interfaces.IAuthorizationToken GetBearerToken(string code, string clientSecret, System.Uri redirectUri);
public MetaBrainz.MusicBrainz.Interfaces.IAuthorizationToken GetBearerToken(string code, string clientSecret, System.Uri redirectUri, string? verifier = null);

public System.Threading.Tasks.Task<MetaBrainz.MusicBrainz.Interfaces.IAuthorizationToken> GetBearerTokenAsync(string code, string clientSecret, System.Uri redirectUri, System.Threading.CancellationToken cancellationToken = default);
public System.Threading.Tasks.Task<MetaBrainz.MusicBrainz.Interfaces.IAuthorizationToken> GetBearerTokenAsync(string code, string clientSecret, System.Uri redirectUri, string? verifier = null, System.Threading.CancellationToken cancellationToken = default);

public MetaBrainz.MusicBrainz.Interfaces.IUserInfo GetUserInfo(string token);

Expand Down Expand Up @@ -1892,8 +1892,9 @@ public interface IStreamingQueryResults<out TItem> : System.Collections.Generic.
```cs
public interface IUserInfo : MetaBrainz.Common.Json.IJsonBasedObject {

string Email {
string? Email {
public abstract get;
public abstract init;
}

string? Gender {
Expand Down