From bbcd33d96e2b46a5fd9ea037e7659b923cc52c5c Mon Sep 17 00:00:00 2001 From: Ken Christensen Date: Fri, 28 Jul 2023 00:46:03 +0200 Subject: [PATCH] Support refreshtokens in OAuth flow (#2749) * 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 --- .../Clients/IObservableOauthClient.cs | 7 ++ .../Clients/ObservableOauthClient.cs | 45 +++--------- Octokit.Tests/Clients/OauthClientTests.cs | 72 ++++++++++++++++++- Octokit/Clients/IOAuthClient.cs | 7 ++ Octokit/Clients/OAuthClient.cs | 43 ++++------- .../Request/OauthTokenRenewalRequest.cs | 67 +++++++++++++++++ Octokit/Models/Response/OauthToken.cs | 59 ++++++++++++++- 7 files changed, 233 insertions(+), 67 deletions(-) create mode 100644 Octokit/Models/Request/OauthTokenRenewalRequest.cs diff --git a/Octokit.Reactive/Clients/IObservableOauthClient.cs b/Octokit.Reactive/Clients/IObservableOauthClient.cs index 88c7a3547d..5313caa819 100644 --- a/Octokit.Reactive/Clients/IObservableOauthClient.cs +++ b/Octokit.Reactive/Clients/IObservableOauthClient.cs @@ -46,5 +46,12 @@ public interface IObservableOauthClient /// The response you received from /// IObservable CreateAccessTokenForDeviceFlow(string clientId, OauthDeviceFlowResponse deviceFlowResponse); + + /// + /// Makes a request to get an access token using the refresh token returned in . + /// + /// Token renewal request. + /// with the new token set. + IObservable CreateAccessTokenFromRenewalToken(OauthTokenRenewalRequest request); } } diff --git a/Octokit.Reactive/Clients/ObservableOauthClient.cs b/Octokit.Reactive/Clients/ObservableOauthClient.cs index fb51cd5c4d..1e4f42ef66 100644 --- a/Octokit.Reactive/Clients/ObservableOauthClient.cs +++ b/Octokit.Reactive/Clients/ObservableOauthClient.cs @@ -3,6 +3,10 @@ namespace Octokit.Reactive { + /// + /// Wrapper around for use with + /// + /// public class ObservableOauthClient : IObservableOauthClient { readonly IGitHubClient _client; @@ -14,59 +18,30 @@ public ObservableOauthClient(IGitHubClient client) _client = client; } - /// - /// Gets the URL used in the first step of the web flow. The Web application should redirect to this URL. - /// - /// Parameters to the Oauth web flow login url - /// public Uri GetGitHubLoginUrl(OauthLoginRequest request) { return _client.Oauth.GetGitHubLoginUrl(request); } - /// - /// Makes a request to get an access token using the code returned when GitHub.com redirects back from the URL - /// GitHub login url to the application. - /// - /// - /// 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. - /// - /// - /// public IObservable CreateAccessToken(OauthTokenRequest request) { return _client.Oauth.CreateAccessToken(request).ToObservable(); } - /// - /// Makes a request to initiate the device flow authentication. - /// - /// - /// 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. - /// - /// - /// public IObservable InitiateDeviceFlow(OauthDeviceFlowRequest request) { return _client.Oauth.InitiateDeviceFlow(request).ToObservable(); } - /// - /// Makes a request to get an access token using the response from . - /// - /// - /// 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. - /// - /// The client Id you received from GitHub when you registered the application. - /// The response you received from - /// public IObservable CreateAccessTokenForDeviceFlow(string clientId, OauthDeviceFlowResponse deviceFlowResponse) { return _client.Oauth.CreateAccessTokenForDeviceFlow(clientId, deviceFlowResponse).ToObservable(); } + + public IObservable CreateAccessTokenFromRenewalToken(OauthTokenRenewalRequest request) + { + return _client.Oauth.CreateAccessTokenFromRenewalToken(request) + .ToObservable(); + } } } diff --git a/Octokit.Tests/Clients/OauthClientTests.cs b/Octokit.Tests/Clients/OauthClientTests.cs index a6f5187024..c0842bb5a5 100644 --- a/Octokit.Tests/Clients/OauthClientTests.cs +++ b/Octokit.Tests/Clients/OauthClientTests.cs @@ -1,5 +1,4 @@ using System; -using System.Linq; using System.Net.Http; using System.Threading.Tasks; using NSubstitute; @@ -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(), null, null, null); + var response = Substitute.For>(); + response.Body.Returns(responseToken); + + var connection = Substitute.For(); + connection.BaseAddress.Returns(new Uri("https://api.github.com/")); + + Uri calledUri = null; + FormUrlEncodedContent calledBody = null; + Uri calledHostAddress = null; + connection.Post( + Arg.Do(uri => calledUri = uri), + Arg.Do(body => calledBody = body as FormUrlEncodedContent), + "application/json", + null, + Arg.Do(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(), null, null, null); + var response = Substitute.For>(); + response.Body.Returns(responseToken); + + var connection = Substitute.For(); + connection.BaseAddress.Returns(new Uri("https://example.com/api/v3")); + + Uri calledUri = null; + FormUrlEncodedContent calledBody = null; + Uri calledHostAddress = null; + connection.Post( + Arg.Do(uri => calledUri = uri), + Arg.Do(body => calledBody = body as FormUrlEncodedContent), + "application/json", + null, + Arg.Do(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()); + } + } } diff --git a/Octokit/Clients/IOAuthClient.cs b/Octokit/Clients/IOAuthClient.cs index 2d8af15299..c1add05062 100644 --- a/Octokit/Clients/IOAuthClient.cs +++ b/Octokit/Clients/IOAuthClient.cs @@ -50,5 +50,12 @@ public interface IOauthClient /// The response you received from /// Task CreateAccessTokenForDeviceFlow(string clientId, OauthDeviceFlowResponse deviceFlowResponse); + + /// + /// Makes a request to get an access token using the refresh token returned in . + /// + /// Token renewal request. + /// with the new token set. + Task CreateAccessTokenFromRenewalToken(OauthTokenRenewalRequest request); } } diff --git a/Octokit/Clients/OAuthClient.cs b/Octokit/Clients/OAuthClient.cs index 75b477f988..9d051d1716 100644 --- a/Octokit/Clients/OAuthClient.cs +++ b/Octokit/Clients/OAuthClient.cs @@ -8,6 +8,7 @@ namespace Octokit /// /// Provides methods used in the OAuth web flow. /// + /// public class OauthClient : IOauthClient { readonly IConnection connection; @@ -46,18 +47,6 @@ public Uri GetGitHubLoginUrl(OauthLoginRequest request) .ApplyParameters(request.ToParametersDictionary()); } - /// - /// Makes a request to get an access token using the code returned when GitHub.com redirects back from the URL - /// GitHub login url to the application. - /// - /// - /// 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. - /// - /// - /// [ManualRoute("POST", "/login/oauth/access_token")] public async Task CreateAccessToken(OauthTokenRequest request) { @@ -71,15 +60,6 @@ public async Task CreateAccessToken(OauthTokenRequest request) return response.Body; } - /// - /// Makes a request to initiate the device flow authentication. - /// - /// - /// 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. - /// - /// - /// [ManualRoute("POST", "/login/device/code")] public async Task InitiateDeviceFlow(OauthDeviceFlowRequest request) { @@ -93,15 +73,6 @@ public async Task InitiateDeviceFlow(OauthDeviceFlowReq return response.Body; } - /// - /// Makes a request to get an access token using the response from . - /// - /// - /// 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. - /// - /// The client Id you received from GitHub when you registered the application. - /// The response you received from - /// [ManualRoute("POST", "/login/oauth/access_token")] public async Task CreateAccessTokenForDeviceFlow(string clientId, OauthDeviceFlowResponse deviceFlowResponse) { @@ -140,5 +111,17 @@ public async Task CreateAccessTokenForDeviceFlow(string clientId, Oa } } } + + [ManualRoute("POST", "/login/oauth/access_token")] + public async Task CreateAccessTokenFromRenewalToken(OauthTokenRenewalRequest request) + { + Ensure.ArgumentNotNull(request, nameof(request)); + + var endPoint = ApiUrls.OauthAccessToken(); + var body = new FormUrlEncodedContent(request.ToParametersDictionary()); + + var response = await connection.Post(endPoint, body, "application/json", null, hostAddress).ConfigureAwait(false); + return response.Body; + } } } diff --git a/Octokit/Models/Request/OauthTokenRenewalRequest.cs b/Octokit/Models/Request/OauthTokenRenewalRequest.cs new file mode 100644 index 0000000000..1bca9b2015 --- /dev/null +++ b/Octokit/Models/Request/OauthTokenRenewalRequest.cs @@ -0,0 +1,67 @@ +using System.Diagnostics; +using System.Globalization; +using Octokit.Internal; + +namespace Octokit +{ + /// + /// Used to create an Oauth login request. + /// + [DebuggerDisplay("{DebuggerDisplay,nq}")] + public class OauthTokenRenewalRequest : RequestParameters + { + /// + /// Creates an instance of the OAuth token refresh request. + /// + /// The client Id you received from GitHub when you registered the application. + /// The client secret you received from GitHub when you registered. + /// The refresh token you received when making the original oauth token request. + 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; + } + + /// + /// The client Id you received from GitHub when you registered the application. + /// + [Parameter(Key = "client_id")] + public string ClientId { get; private set; } + + /// + /// The client secret you received from GitHub when you registered. + /// + [Parameter(Key = "client_secret")] + public string ClientSecret { get; private set; } + + /// + /// The type of grant. Should be ommited, unless renewing an access token. + /// + [Parameter(Key = "grant_type")] + public string GrantType { get; private set; } = "refresh_token"; + + /// + /// The refresh token you received as a response to making the OAuth login + /// request. + /// + [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); + } + } + } +} diff --git a/Octokit/Models/Response/OauthToken.cs b/Octokit/Models/Response/OauthToken.cs index b7ecd50b88..38dc98876d 100644 --- a/Octokit/Models/Response/OauthToken.cs +++ b/Octokit/Models/Response/OauthToken.cs @@ -1,4 +1,5 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; using System.Diagnostics; using System.Globalization; using Octokit.Internal; @@ -10,6 +11,17 @@ public class OauthToken { public OauthToken() { } + /// + /// Initializes a new instance of the class. + /// Use this constructor when you don't have a refreshToken. + /// + /// The type of token returned by GitHub. + /// The access token returned by GitHub. + /// The auhtorization scope of the returned token. + /// The error code returned by the GitHub API. + /// The error message, if any, returned by the GitHub API. + /// The GitHub documentation link, detailing the error message. + [Obsolete("This constructor is being deprecated and will be removed in the future. Use OauthToken.OauthToken (with refreshToken paramters) instead.")] public OauthToken(string tokenType, string accessToken, IReadOnlyList scope, string error, string errorDescription, string errorUri) { this.TokenType = tokenType; @@ -20,6 +32,32 @@ public OauthToken(string tokenType, string accessToken, IReadOnlyList sc this.ErrorUri = errorUri; } + /// + /// Initializes a new instance of the class. + /// Use this constructor by default. + /// + /// The type of token returned by GitHub. + /// The access token returned by GitHub. + /// The amount of seconds, before the access token expires. + /// The refresh token returned by GitHub. Use this, to get a new access token if it expires. + /// The amount of seconds, before the refresh token expires. + /// The auhtorization scope of the returned token. + /// The error code returned by the GitHub API. + /// The error message, if any, returned by the GitHub API. + /// The GitHub documentation link, detailing the error message. + public OauthToken(string tokenType, string accessToken, int expiresIn, string refreshToken, int refreshTokenExpiresIn, IReadOnlyList scope, string error, string errorDescription, string errorUri) + { + 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; + } + /// /// The type of OAuth token /// @@ -30,6 +68,25 @@ public OauthToken(string tokenType, string accessToken, IReadOnlyList sc /// public string AccessToken { get; private set; } + /// + /// The amount of seconds, until the acces token expires. + /// + [Parameter(Key = "expires_in")] + public int ExpiresIn { get; private set; } + + /// + /// The secret refresh token. + /// Use this to get a new access token, without going through the OAuth flow again. + /// + [Parameter(Key = "refresh_token")] + public string RefreshToken { get; private set; } + + /// + /// The amount of seconds, until the refresh token expires. + /// + [Parameter(Key = "refresh_token_expires_in")] + public int RefreshTokenExpiresIn { get; private set; } + /// /// The list of scopes the token includes. ///