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

Support refreshtokens in OAuth flow #2749

Merged
merged 6 commits into from
Jul 27, 2023
Merged
Show file tree
Hide file tree
Changes from 2 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
7 changes: 7 additions & 0 deletions Octokit.Reactive/Clients/IObservableOauthClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -46,5 +46,12 @@ public interface IObservableOauthClient
/// <param name="deviceFlowResponse">The response you received from <see cref="InitiateDeviceFlow(OauthDeviceFlowRequest)"/></param>
/// <returns></returns>
IObservable<OauthToken> CreateAccessTokenForDeviceFlow(string clientId, OauthDeviceFlowResponse deviceFlowResponse);

/// <summary>
/// Makes a request to get an access token using the refresh token returned in <see cref="CreateAccessToken(OauthTokenRequest)"/>.
/// </summary>
/// <param name="request">Token renewal request.</param>
/// <returns><see cref="OauthToken"/> with the new token set.</returns>
IObservable<OauthToken> CreateAccessTokenFromRenewalToken(OauthTokenRenewalRequest request);
}
}
45 changes: 10 additions & 35 deletions Octokit.Reactive/Clients/ObservableOauthClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,10 @@

namespace Octokit.Reactive
{
/// <summary>
/// Wrapper around <see cref="IOauthClient"/> for use with <see cref="IObservable{T}"/>
/// </summary>
/// <inheritdoc />
public class ObservableOauthClient : IObservableOauthClient
{
readonly IGitHubClient _client;
Expand All @@ -14,59 +18,30 @@ public ObservableOauthClient(IGitHubClient client)
_client = client;
}

/// <summary>
/// Gets the URL used in the first step of the web flow. The Web application should redirect to this URL.
/// </summary>
/// <param name="request">Parameters to the Oauth web flow login url</param>
/// <returns></returns>
kfcampbell marked this conversation as resolved.
Show resolved Hide resolved
public Uri GetGitHubLoginUrl(OauthLoginRequest request)
{
return _client.Oauth.GetGitHubLoginUrl(request);
}

/// <summary>
/// Makes a request to get an access token using the code returned when GitHub.com redirects back from the URL
/// <see cref="GetGitHubLoginUrl">GitHub login url</see> to the application.
/// </summary>
/// <remarks>
/// If the user accepts your request, GitHub redirects back to your site with a temporary code in a code
/// parameter as well as the state you provided in the previous step in a state parameter. If the states don’t
/// match, the request has been created by a third party and the process should be aborted. Exchange this for
/// an access token using this method.
/// </remarks>
/// <param name="request"></param>
/// <returns></returns>
public IObservable<OauthToken> CreateAccessToken(OauthTokenRequest request)
{
return _client.Oauth.CreateAccessToken(request).ToObservable();
}

/// <summary>
/// Makes a request to initiate the device flow authentication.
/// </summary>
/// <remarks>
/// Returns a user verification code and verification URL that the you will use to prompt the user to authenticate.
/// This request also returns a device verification code that you must use to receive an access token to check the status of user authentication.
/// </remarks>
/// <param name="request"></param>
/// <returns></returns>
public IObservable<OauthDeviceFlowResponse> InitiateDeviceFlow(OauthDeviceFlowRequest request)
{
return _client.Oauth.InitiateDeviceFlow(request).ToObservable();
}

/// <summary>
/// Makes a request to get an access token using the response from <see cref="InitiateDeviceFlow(OauthDeviceFlowRequest)"/>.
/// </summary>
/// <remarks>
/// Will poll the access token endpoint, until the device and user codes expire or the user has successfully authorized the app with a valid user code.
/// </remarks>
/// <param name="clientId">The client Id you received from GitHub when you registered the application.</param>
/// <param name="deviceFlowResponse">The response you received from <see cref="InitiateDeviceFlow(OauthDeviceFlowRequest)"/></param>
/// <returns></returns>
public IObservable<OauthToken> CreateAccessTokenForDeviceFlow(string clientId, OauthDeviceFlowResponse deviceFlowResponse)
{
return _client.Oauth.CreateAccessTokenForDeviceFlow(clientId, deviceFlowResponse).ToObservable();
}

public IObservable<OauthToken> CreateAccessTokenFromRenewalToken(OauthTokenRenewalRequest request)
{
return _client.Oauth.CreateAccessTokenFromRenewalToken(request)
.ToObservable();
}
}
}
72 changes: 71 additions & 1 deletion Octokit.Tests/Clients/OauthClientTests.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
using System;
using System.Linq;
using System.Net.Http;
using System.Threading.Tasks;
using NSubstitute;
Expand Down Expand Up @@ -208,4 +207,75 @@ public async Task DeserializesOAuthScopeFormat()
Assert.Contains("user:email", token.Scope);
}
}

public class TheCreateAccessTokenFromRenewalTokenMethod
{
[Fact]
public async Task PostsWithCorrectBodyAndContentType()
{
var responseToken = new OauthToken("bearer", "someaccesstoken", 3000, "refreshtoken", 10000, Array.Empty<string>(), null, null, null);
var response = Substitute.For<IApiResponse<OauthToken>>();
response.Body.Returns(responseToken);

var connection = Substitute.For<IConnection>();
connection.BaseAddress.Returns(new Uri("https://api.github.com/"));

Uri calledUri = null;
FormUrlEncodedContent calledBody = null;
Uri calledHostAddress = null;
connection.Post<OauthToken>(
Arg.Do<Uri>(uri => calledUri = uri),
Arg.Do<object>(body => calledBody = body as FormUrlEncodedContent),
"application/json",
null,
Arg.Do<Uri>(uri => calledHostAddress = uri))
.Returns(_ => Task.FromResult(response));
var client = new OauthClient(connection);

var token = await client.CreateAccessTokenFromRenewalToken(
new OauthTokenRenewalRequest("secretid", "secretsecret", "refreshToken"));

Assert.Same(responseToken, token);
Assert.Equal("login/oauth/access_token", calledUri.ToString());
Assert.NotNull(calledBody);
Assert.Equal("https://github.com/", calledHostAddress.ToString());
Assert.Equal(
"client_id=secretid&client_secret=secretsecret&grant_type=refresh_token&refresh_token=refreshToken",
await calledBody.ReadAsStringAsync());
}

[Fact]
public async Task PostsWithCorrectBodyAndContentTypeForGHE()
{
var responseToken = new OauthToken("bearer", "someaccesstoken", 3000, "refreshtoken", 10000, Array.Empty<string>(), null, null, null);
var response = Substitute.For<IApiResponse<OauthToken>>();
response.Body.Returns(responseToken);

var connection = Substitute.For<IConnection>();
connection.BaseAddress.Returns(new Uri("https://example.com/api/v3"));

Uri calledUri = null;
FormUrlEncodedContent calledBody = null;
Uri calledHostAddress = null;
connection.Post<OauthToken>(
Arg.Do<Uri>(uri => calledUri = uri),
Arg.Do<object>(body => calledBody = body as FormUrlEncodedContent),
"application/json",
null,
Arg.Do<Uri>(uri => calledHostAddress = uri))
.Returns(_ => Task.FromResult(response));
var client = new OauthClient(connection);

var token = await client.CreateAccessTokenFromRenewalToken(
new OauthTokenRenewalRequest("secretid", "secretsecret", "refreshToken"));

Assert.Same(responseToken, token);
Assert.Equal("login/oauth/access_token", calledUri.ToString());
Assert.NotNull(calledBody);
Assert.Equal("https://example.com/", calledHostAddress.ToString());
Assert.Equal(
"client_id=secretid&client_secret=secretsecret&grant_type=refresh_token&refresh_token=refreshToken",
await calledBody.ReadAsStringAsync());
}
}
}
7 changes: 7 additions & 0 deletions Octokit/Clients/IOAuthClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -50,5 +50,12 @@ public interface IOauthClient
/// <param name="deviceFlowResponse">The response you received from <see cref="InitiateDeviceFlow(OauthDeviceFlowRequest)"/></param>
/// <returns></returns>
Task<OauthToken> CreateAccessTokenForDeviceFlow(string clientId, OauthDeviceFlowResponse deviceFlowResponse);

/// <summary>
/// Makes a request to get an access token using the refresh token returned in <see cref="CreateAccessToken(OauthTokenRequest)"/>.
/// </summary>
/// <param name="request">Token renewal request.</param>
/// <returns><see cref="OauthToken"/> with the new token set.</returns>
Task<OauthToken> CreateAccessTokenFromRenewalToken(OauthTokenRenewalRequest request);
}
}
43 changes: 13 additions & 30 deletions Octokit/Clients/OAuthClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ namespace Octokit
/// <summary>
/// Provides methods used in the OAuth web flow.
/// </summary>
/// <inheritdoc />
public class OauthClient : IOauthClient
{
readonly IConnection connection;
Expand Down Expand Up @@ -46,18 +47,6 @@ public Uri GetGitHubLoginUrl(OauthLoginRequest request)
.ApplyParameters(request.ToParametersDictionary());
}

/// <summary>
/// Makes a request to get an access token using the code returned when GitHub.com redirects back from the URL
/// <see cref="GetGitHubLoginUrl">GitHub login url</see> to the application.
/// </summary>
/// <remarks>
/// If the user accepts your request, GitHub redirects back to your site with a temporary code in a code
/// parameter as well as the state you provided in the previous step in a state parameter. If the states don’t
/// match, the request has been created by a third party and the process should be aborted. Exchange this for
/// an access token using this method.
/// </remarks>
/// <param name="request"></param>
/// <returns></returns>
[ManualRoute("POST", "/login/oauth/access_token")]
public async Task<OauthToken> CreateAccessToken(OauthTokenRequest request)
{
Expand All @@ -71,15 +60,6 @@ public async Task<OauthToken> CreateAccessToken(OauthTokenRequest request)
return response.Body;
}

/// <summary>
/// Makes a request to initiate the device flow authentication.
/// </summary>
/// <remarks>
/// Returns a user verification code and verification URL that the you will use to prompt the user to authenticate.
/// This request also returns a device verification code that you must use to receive an access token to check the status of user authentication.
/// </remarks>
/// <param name="request"></param>
/// <returns></returns>
[ManualRoute("POST", "/login/device/code")]
public async Task<OauthDeviceFlowResponse> InitiateDeviceFlow(OauthDeviceFlowRequest request)
{
Expand All @@ -93,15 +73,6 @@ public async Task<OauthDeviceFlowResponse> InitiateDeviceFlow(OauthDeviceFlowReq
return response.Body;
}

/// <summary>
/// Makes a request to get an access token using the response from <see cref="InitiateDeviceFlow(OauthDeviceFlowRequest)"/>.
/// </summary>
/// <remarks>
/// Will poll the access token endpoint, until the device and user codes expire or the user has successfully authorized the app with a valid user code.
/// </remarks>
/// <param name="clientId">The client Id you received from GitHub when you registered the application.</param>
/// <param name="deviceFlowResponse">The response you received from <see cref="InitiateDeviceFlow(OauthDeviceFlowRequest)"/></param>
/// <returns></returns>
[ManualRoute("POST", "/login/oauth/access_token")]
public async Task<OauthToken> CreateAccessTokenForDeviceFlow(string clientId, OauthDeviceFlowResponse deviceFlowResponse)
{
Expand Down Expand Up @@ -140,5 +111,17 @@ public async Task<OauthToken> CreateAccessTokenForDeviceFlow(string clientId, Oa
}
}
}

[ManualRoute("POST", "/login/oauth/access_token")]
public async Task<OauthToken> CreateAccessTokenFromRenewalToken(OauthTokenRenewalRequest request)
{
Ensure.ArgumentNotNull(request, nameof(request));

var endPoint = ApiUrls.OauthAccessToken();
var body = new FormUrlEncodedContent(request.ToParametersDictionary());

var response = await connection.Post<OauthToken>(endPoint, body, "application/json", null, hostAddress).ConfigureAwait(false);
return response.Body;
}
}
}
67 changes: 67 additions & 0 deletions Octokit/Models/Request/OauthTokenRenewalRequest.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
using System.Diagnostics;
using System.Globalization;
using Octokit.Internal;

namespace Octokit
{
/// <summary>
/// Used to create an Oauth login request.
/// </summary>
[DebuggerDisplay("{DebuggerDisplay,nq}")]
public class OauthTokenRenewalRequest : RequestParameters
{
/// <summary>
/// Creates an instance of the OAuth token refresh request.
/// </summary>
/// <param name="clientId">The client Id you received from GitHub when you registered the application.</param>
/// <param name="clientSecret">The client secret you received from GitHub when you registered.</param>
/// <param name="refreshToken">The refresh token you received when making the original oauth token request.</param>
public OauthTokenRenewalRequest(string clientId, string clientSecret, string refreshToken)
{
Ensure.ArgumentNotNullOrEmptyString(clientId, nameof(clientId));
Ensure.ArgumentNotNullOrEmptyString(clientSecret, nameof(clientSecret));
Ensure.ArgumentNotNullOrEmptyString(refreshToken, nameof(refreshToken));

ClientId = clientId;
ClientSecret = clientSecret;
RefreshToken = refreshToken;
}

/// <summary>
/// The client Id you received from GitHub when you registered the application.
/// </summary>
[Parameter(Key = "client_id")]
public string ClientId { get; private set; }

/// <summary>
/// The client secret you received from GitHub when you registered.
/// </summary>
[Parameter(Key = "client_secret")]
public string ClientSecret { get; private set; }

/// <summary>
/// The type of grant. Should be ommited, unless renewing an access token.
/// </summary>
[Parameter(Key = "grant_type")]
public string GrantType { get; private set; } = "refresh_token";

/// <summary>
/// The refresh token you received as a response to making the <see cref="IOauthClient.CreateAccessToken">OAuth login
/// request</see>.
/// </summary>
[Parameter(Key = "refresh_token")]
public string RefreshToken { get; private set; }

internal string DebuggerDisplay
{
get
{
return string.Format(CultureInfo.InvariantCulture, "ClientId: {0}, ClientSecret: {1}, GrantType: {2}, RefreshToken: {3}",
ClientId,
ClientSecret,
GrantType,
RefreshToken);
}
}
}
}
32 changes: 32 additions & 0 deletions Octokit/Models/Response/OauthToken.cs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,19 @@ public OauthToken(string tokenType, string accessToken, IReadOnlyList<string> sc
this.ErrorUri = errorUri;
}

public OauthToken(string tokenType, string accessToken, int expiresIn, string refreshToken, int refreshTokenExpiresIn, IReadOnlyList<string> scope, string error, string errorDescription, string errorUri)
Copy link
Member

Choose a reason for hiding this comment

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

Do you think it'd be worthwhile to add a comment here and above describing the situations when you'd want to use each constructor?

Copy link
Contributor Author

@Kencdk Kencdk Jul 24, 2023

Choose a reason for hiding this comment

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

I primarily kept the old one, to keep the change binary compatible - to avoid breaking changes :)
Added a summary for good measure.

These constructors aren't really meant to be used manually - so wondering if the one without the refresh token should be marked as deprecated, so it can be removed sometime in the future? :)

Copy link
Member

Choose a reason for hiding this comment

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

Got it, thank you! I think it's reasonable to mark the old one as deprecated to be removed in a future major release. After that's done, I can merge and release this!

{
this.TokenType = tokenType;
this.AccessToken = accessToken;
this.ExpiresIn = expiresIn;
this.RefreshToken = refreshToken;
this.RefreshTokenExpiresIn = refreshTokenExpiresIn;
this.Scope = scope;
this.Error = error;
this.ErrorDescription = errorDescription;
this.ErrorUri = errorUri;
}

/// <summary>
/// The type of OAuth token
/// </summary>
Expand All @@ -30,6 +43,25 @@ public OauthToken(string tokenType, string accessToken, IReadOnlyList<string> sc
/// </summary>
public string AccessToken { get; private set; }

/// <summary>
/// The amount of seconds, until the acces token expires.
/// </summary>
[Parameter(Key = "expires_in")]
public int ExpiresIn { get; private set; }

/// <summary>
/// The secret refresh token.
/// Use this to get a new access token, without going through the OAuth flow again.
/// </summary>
[Parameter(Key = "refresh_token")]
public string RefreshToken { get; private set; }

/// <summary>
/// The amount of seconds, until the refresh token expires.
/// </summary>
[Parameter(Key = "refresh_token_expires_in")]
public int RefreshTokenExpiresIn { get; private set; }

/// <summary>
/// The list of scopes the token includes.
/// </summary>
Expand Down
Loading