diff --git a/Octokit.Reactive/Clients/IObservableOauthClient.cs b/Octokit.Reactive/Clients/IObservableOauthClient.cs index 2afa944c00..88c7a3547d 100644 --- a/Octokit.Reactive/Clients/IObservableOauthClient.cs +++ b/Octokit.Reactive/Clients/IObservableOauthClient.cs @@ -24,5 +24,27 @@ public interface IObservableOauthClient /// /// IObservable CreateAccessToken(OauthTokenRequest request); + + /// + /// 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. + /// + /// + /// + IObservable InitiateDeviceFlow(OauthDeviceFlowRequest request); + + /// + /// 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 + /// + IObservable CreateAccessTokenForDeviceFlow(string clientId, OauthDeviceFlowResponse deviceFlowResponse); } } diff --git a/Octokit.Reactive/Clients/ObservableOauthClient.cs b/Octokit.Reactive/Clients/ObservableOauthClient.cs index 62cce34790..fb51cd5c4d 100644 --- a/Octokit.Reactive/Clients/ObservableOauthClient.cs +++ b/Octokit.Reactive/Clients/ObservableOauthClient.cs @@ -40,5 +40,33 @@ 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(); + } } } diff --git a/Octokit.Tests/Clients/OauthClientTests.cs b/Octokit.Tests/Clients/OauthClientTests.cs index b2bf4228ac..a6f5187024 100644 --- a/Octokit.Tests/Clients/OauthClientTests.cs +++ b/Octokit.Tests/Clients/OauthClientTests.cs @@ -65,7 +65,7 @@ public class TheCreateAccessTokenMethod [Fact] public async Task PostsWithCorrectBodyAndContentType() { - var responseToken = new OauthToken(null, null, null); + var responseToken = new OauthToken(null, null, null, null, null, null); var response = Substitute.For>(); response.Body.Returns(responseToken); var connection = Substitute.For(); @@ -99,7 +99,7 @@ public async Task PostsWithCorrectBodyAndContentType() [Fact] public async Task PostsWithCorrectBodyAndContentTypeForGHE() { - var responseToken = new OauthToken(null, null, null); + var responseToken = new OauthToken(null, null, null, null, null, null); var response = Substitute.For>(); response.Body.Returns(responseToken); var connection = Substitute.For(); @@ -130,6 +130,68 @@ public async Task PostsWithCorrectBodyAndContentTypeForGHE() await calledBody.ReadAsStringAsync()); } + [Fact] + public async Task InitiateDeviceFlowPostsWithCorrectBodyAndContentType() + { + var responseToken = new OauthDeviceFlowResponse("devicecode", "usercode", "uri", 10, 5); + 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.InitiateDeviceFlow(new OauthDeviceFlowRequest("clientid")); + + Assert.Same(responseToken, token); + Assert.Equal("login/device/code", calledUri.ToString()); + Assert.NotNull(calledBody); + Assert.Equal("https://github.com/", calledHostAddress.ToString()); + Assert.Equal( + "client_id=clientid", + await calledBody.ReadAsStringAsync()); + } + + [Fact] + public async Task CreateAccessTokenForDeviceFlowPostsWithCorrectBodyAndContentType() + { + var responseToken = new OauthToken(null, null, null, 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.CreateAccessTokenForDeviceFlow("clientid", new OauthDeviceFlowResponse("devicecode", "usercode", "uri", 10, 5)); + + 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=clientid&device_code=devicecode&grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Adevice_code", + await calledBody.ReadAsStringAsync()); + } + [Fact] public async Task DeserializesOAuthScopeFormat() { diff --git a/Octokit/Clients/IOAuthClient.cs b/Octokit/Clients/IOAuthClient.cs index b0bde0191a..2d8af15299 100644 --- a/Octokit/Clients/IOAuthClient.cs +++ b/Octokit/Clients/IOAuthClient.cs @@ -28,5 +28,27 @@ public interface IOauthClient /// /// Task CreateAccessToken(OauthTokenRequest request); + + /// + /// 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. + /// + /// + /// + Task InitiateDeviceFlow(OauthDeviceFlowRequest request); + + /// + /// 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 + /// + Task CreateAccessTokenForDeviceFlow(string clientId, OauthDeviceFlowResponse deviceFlowResponse); } } diff --git a/Octokit/Clients/OAuthClient.cs b/Octokit/Clients/OAuthClient.cs index 655fe00be9..75b477f988 100644 --- a/Octokit/Clients/OAuthClient.cs +++ b/Octokit/Clients/OAuthClient.cs @@ -1,4 +1,5 @@ using System; +using System.Globalization; using System.Net.Http; using System.Threading.Tasks; @@ -69,5 +70,75 @@ public async Task CreateAccessToken(OauthTokenRequest request) var response = await connection.Post(endPoint, body, "application/json", null, hostAddress).ConfigureAwait(false); 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) + { + Ensure.ArgumentNotNull(request, nameof(request)); + + var endPoint = ApiUrls.OauthDeviceCode(); + + var body = new FormUrlEncodedContent(request.ToParametersDictionary()); + + var response = await connection.Post(endPoint, body, "application/json", null, hostAddress).ConfigureAwait(false); + 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) + { + Ensure.ArgumentNotNullOrEmptyString(clientId, nameof(clientId)); + Ensure.ArgumentNotNull(deviceFlowResponse, nameof(deviceFlowResponse)); + + var endPoint = ApiUrls.OauthAccessToken(); + + int pollingDelay = deviceFlowResponse.Interval; + + while (true) + { + var request = new OauthTokenRequestForDeviceFlow(clientId, deviceFlowResponse.DeviceCode); + var body = new FormUrlEncodedContent(request.ToParametersDictionary()); + var response = await connection.Post(endPoint, body, "application/json", null, hostAddress).ConfigureAwait(false); + + if (response.Body.Error != null) + { + switch (response.Body.Error) + { + case "authorization_pending": + break; + case "slow_down": + pollingDelay += 5; + break; + case "expired_token": + default: + throw new ApiException(string.Format(CultureInfo.InvariantCulture, "{0}: {1}\n{2}", response.Body.Error, response.Body.ErrorDescription, response.Body.ErrorUri), null); + } + + await Task.Delay(TimeSpan.FromSeconds(pollingDelay)); + } + else + { + return response.Body; + } + } + } } } diff --git a/Octokit/Helpers/ApiUrls.cs b/Octokit/Helpers/ApiUrls.cs index 74f0ff3f9c..48b5660ac5 100644 --- a/Octokit/Helpers/ApiUrls.cs +++ b/Octokit/Helpers/ApiUrls.cs @@ -2350,6 +2350,15 @@ public static Uri OauthAuthorize() return "login/oauth/authorize".FormatUri(); } + /// + /// Creates the relative for initiating the OAuth device Flow + /// + /// + public static Uri OauthDeviceCode() + { + return "login/device/code".FormatUri(); + } + /// /// Creates the relative to request an OAuth access token. /// diff --git a/Octokit/Models/Request/OauthDeviceFlowRequest.cs b/Octokit/Models/Request/OauthDeviceFlowRequest.cs new file mode 100644 index 0000000000..5e3ba24cbb --- /dev/null +++ b/Octokit/Models/Request/OauthDeviceFlowRequest.cs @@ -0,0 +1,54 @@ +using System.Collections.ObjectModel; +using System.Diagnostics; +using System.Globalization; +using Octokit.Internal; + +namespace Octokit +{ + /// + /// Used to create an Oauth device flow initiation request. + /// + [DebuggerDisplay("{DebuggerDisplay,nq}")] + public class OauthDeviceFlowRequest + : RequestParameters + { + /// + /// Creates an instance of the OAuth login request with the required parameter. + /// + /// The client Id you received from GitHub when you registered the application. + public OauthDeviceFlowRequest(string clientId) + { + Ensure.ArgumentNotNullOrEmptyString(clientId, nameof(clientId)); + + ClientId = clientId; + Scopes = new Collection(); + } + + /// + /// The client Id you received from GitHub when you registered the application. + /// + [Parameter(Key = "client_id")] + public string ClientId { get; private set; } + + /// + /// A set of scopes to request. If not provided, scope defaults to an empty list of scopes for users that don’t + /// have a valid token for the app. For users who do already have a valid token for the app, the user won't be + /// shown the OAuth authorization page with the list of scopes. Instead, this step of the flow will + /// automatically complete with the same scopes that were used last time the user completed the flow. + /// + /// + /// See the scopes documentation for more + /// information about scopes. + /// + [Parameter(Key = "scope")] + public Collection Scopes { get; private set; } + + internal string DebuggerDisplay + { + get + { + return string.Format(CultureInfo.InvariantCulture, "ClientId: {0}, Scopes: {1}", ClientId, Scopes); + } + } + } +} diff --git a/Octokit/Models/Request/OauthTokenRequestForDeviceFlow.cs b/Octokit/Models/Request/OauthTokenRequestForDeviceFlow.cs new file mode 100644 index 0000000000..b53e1406ac --- /dev/null +++ b/Octokit/Models/Request/OauthTokenRequestForDeviceFlow.cs @@ -0,0 +1,55 @@ +using System.Diagnostics; +using System.Globalization; +using Octokit.Internal; + +namespace Octokit +{ + /// + /// Used to create an Oauth login request for the device flow. + /// + [DebuggerDisplay("{DebuggerDisplay,nq}")] + internal class OauthTokenRequestForDeviceFlow : RequestParameters + { + /// + /// Creates an instance of the OAuth login request with the required parameter. + /// + /// The client Id you received from GitHub when you registered the application. + /// The device code you received from the device flow initiation call. + public OauthTokenRequestForDeviceFlow(string clientId, string deviceCode) + { + Ensure.ArgumentNotNullOrEmptyString(clientId, nameof(clientId)); + Ensure.ArgumentNotNullOrEmptyString(deviceCode, nameof(deviceCode)); + + ClientId = clientId; + DeviceCode = deviceCode; + } + + /// + /// The client Id you received from GitHub when you registered the application. + /// + [Parameter(Key = "client_id")] + public string ClientId { get; private set; } + + /// + /// The device code you received from the device flow initiation call. + /// + [Parameter(Key = "device_code")] + public string DeviceCode { get; private set; } + + /// + /// The authorization grant type. + /// + [Parameter(Key = "grant_type")] + public string GrantType => "urn:ietf:params:oauth:grant-type:device_code"; + + internal string DebuggerDisplay + { + get + { + return string.Format(CultureInfo.InvariantCulture, "ClientId: {0}, DeviceCode: {1}", + ClientId, + DeviceCode); + } + } + } +} diff --git a/Octokit/Models/Response/OauthDeviceFlowResponse.cs b/Octokit/Models/Response/OauthDeviceFlowResponse.cs new file mode 100644 index 0000000000..2f3ab0ad94 --- /dev/null +++ b/Octokit/Models/Response/OauthDeviceFlowResponse.cs @@ -0,0 +1,61 @@ +using System.Collections.Generic; +using System.Diagnostics; +using System.Globalization; + +namespace Octokit +{ + [DebuggerDisplay("{DebuggerDisplay,nq}")] + public class OauthDeviceFlowResponse + { + public OauthDeviceFlowResponse() { } + + public OauthDeviceFlowResponse(string deviceCode, string userCode, string verificationUri, int expiresIn, int interval) + { + DeviceCode = deviceCode; + UserCode = userCode; + VerificationUri = verificationUri; + ExpiresIn = expiresIn; + Interval = interval; + } + + /// + /// The device verification code is 40 characters and used to verify the device. + /// + public string DeviceCode { get; private set; } + + /// + /// The user verification code is displayed on the device so the user can enter the code in a browser. This code is 8 characters with a hyphen in the middle. + /// + public string UserCode { get; private set; } + + /// + /// The verification URL where users need to enter the UserCode: https://github.com/login/device. + /// + public string VerificationUri { get; private set; } + + /// + /// The number of seconds before the DeviceCode and UserCode expire. The default is 900 seconds or 15 minutes. + /// + public int ExpiresIn { get; private set; } + + /// + /// The minimum number of seconds that must pass before you can make a new access token request (POST https://github.com/login/oauth/access_token) to complete the device authorization. + /// For example, if the interval is 5, then you cannot make a new request until 5 seconds pass. If you make more than one request over 5 seconds, then you will hit the rate limit + /// and receive a slow_down error. + /// + public int Interval { get; private set; } + + internal string DebuggerDisplay + { + get + { + return string.Format(CultureInfo.InvariantCulture, "DeviceCode: {0}, UserCode: {1}, VerificationUri: {2}, ExpiresIn: {3}, Interval: {4}", + DeviceCode, + UserCode, + VerificationUri, + ExpiresIn, + Interval); + } + } + } +} diff --git a/Octokit/Models/Response/OauthToken.cs b/Octokit/Models/Response/OauthToken.cs index e61f2b1ea2..b7ecd50b88 100644 --- a/Octokit/Models/Response/OauthToken.cs +++ b/Octokit/Models/Response/OauthToken.cs @@ -1,6 +1,7 @@ using System.Collections.Generic; using System.Diagnostics; using System.Globalization; +using Octokit.Internal; namespace Octokit { @@ -9,36 +10,57 @@ public class OauthToken { public OauthToken() { } - public OauthToken(string tokenType, string accessToken, IReadOnlyList scope) + public OauthToken(string tokenType, string accessToken, IReadOnlyList scope, string error, string errorDescription, string errorUri) { - TokenType = tokenType; - AccessToken = accessToken; - Scope = scope; + this.TokenType = tokenType; + this.AccessToken = accessToken; + this.Scope = scope; + this.Error = error; + this.ErrorDescription = errorDescription; + this.ErrorUri = errorUri; } /// /// The type of OAuth token /// - public string TokenType { get; protected set; } + public string TokenType { get; private set; } /// /// The secret OAuth access token. Use this to authenticate Octokit.net's client. /// - public string AccessToken { get; protected set; } + public string AccessToken { get; private set; } /// /// The list of scopes the token includes. /// - public IReadOnlyList Scope { get; protected set; } + public IReadOnlyList Scope { get; private set; } + + /// + /// Gets or sets the error code or the response. + /// + [Parameter(Key = "error")] + public string Error { get; private set; } + + /// + /// Gets or sets the error description. + /// + [Parameter(Key = "error_description")] + public string ErrorDescription { get; private set; } + + /// + /// Gets or sets the error uri. + /// + [Parameter(Key = "error_uri")] + public string ErrorUri { get; private set; } internal string DebuggerDisplay { get { return string.Format(CultureInfo.InvariantCulture, "TokenType: {0}, AccessToken: {1}, Scopes: {2}", - TokenType, - AccessToken, - Scope); + this.TokenType, + this.AccessToken, + this.Scope); } } }