Skip to content

Commit

Permalink
Support refreshtokens in OAuth flow (#2749)
Browse files Browse the repository at this point in the history
* Support refreshtokens in OAuth flow
Fixes #2731

* Added summary to OauthToken.cs constructors

* Mark deprecation of non-refreshToken constructor for OauthToken

* Remove unnecessary comment

---------

Co-authored-by: Keegan Campbell <me@kfcampbell.com>
  • Loading branch information
Kencdk and kfcampbell committed Jul 27, 2023
1 parent 8974796 commit bbcd33d
Show file tree
Hide file tree
Showing 7 changed files with 233 additions and 67 deletions.
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>
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);
}
}
}
}
Loading

0 comments on commit bbcd33d

Please sign in to comment.