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

Adding support for the Device Flow Oauth authentication pattern #2310

Merged
merged 6 commits into from
Apr 20, 2022
Merged
Show file tree
Hide file tree
Changes from 4 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
22 changes: 22 additions & 0 deletions Octokit.Reactive/Clients/IObservableOauthClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -24,5 +24,27 @@ public interface IObservableOauthClient
/// <param name="request"></param>
/// <returns></returns>
IObservable<OauthToken> CreateAccessToken(OauthTokenRequest request);

/// <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>
IObservable<OauthDeviceFlowResponse> InitiateDeviceFlow(OauthDeviceFlowRequest request);

/// <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>
IObservable<OauthToken> CreateAccessTokenForDeviceFlow(string clientId, OauthDeviceFlowResponse deviceFlowResponse);
}
}
28 changes: 28 additions & 0 deletions Octokit.Reactive/Clients/ObservableOauthClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -40,5 +40,33 @@ 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();
}
}
}
62 changes: 62 additions & 0 deletions Octokit.Tests/Clients/OauthClientTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<IApiResponse<OauthDeviceFlowResponse>>();
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<OauthDeviceFlowResponse>(
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.InitiateDeviceFlow(new OauthDeviceFlowRequest("clientid"));

Assert.Same(responseToken, token);
Assert.Equal("login/device/code", calledUri.ToString());
Assert.NotNull(calledBody);
Assert.Equal("https:/", calledHostAddress.ToString());
Assert.Equal(
"client_id=clientid",
await calledBody.ReadAsStringAsync());
}

[Fact]
public async Task CreateAccessTokenForDeviceFlowPostsWithCorrectBodyAndContentType()
{
var responseToken = new OauthToken(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.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:/", 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()
{
Expand Down
22 changes: 22 additions & 0 deletions Octokit/Clients/IOAuthClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -28,5 +28,27 @@ public interface IOauthClient
/// <param name="request"></param>
/// <returns></returns>
Task<OauthToken> CreateAccessToken(OauthTokenRequest request);

/// <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>
Task<OauthDeviceFlowResponse> InitiateDeviceFlow(OauthDeviceFlowRequest request);

/// <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>
Task<OauthToken> CreateAccessTokenForDeviceFlow(string clientId, OauthDeviceFlowResponse deviceFlowResponse);
}
}
71 changes: 71 additions & 0 deletions Octokit/Clients/OAuthClient.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System;
using System.Globalization;
using System.Net.Http;
using System.Threading.Tasks;

Expand Down Expand Up @@ -69,5 +70,75 @@ public async Task<OauthToken> CreateAccessToken(OauthTokenRequest request)
var response = await connection.Post<OauthToken>(endPoint, body, "application/json", null, hostAddress).ConfigureAwait(false);
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)
{
Ensure.ArgumentNotNull(request, nameof(request));

var endPoint = ApiUrls.OauthDeviceCode();

var body = new FormUrlEncodedContent(request.ToParametersDictionary());

var response = await connection.Post<OauthDeviceFlowResponse>(endPoint, body, "application/json", null, hostAddress).ConfigureAwait(false);
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)
{
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<OauthToken>(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;
}
}
}
}
}
9 changes: 9 additions & 0 deletions Octokit/Helpers/ApiUrls.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2350,6 +2350,15 @@ public static Uri OauthAuthorize()
return "login/oauth/authorize".FormatUri();
}

/// <summary>
/// Creates the relative <see cref="Uri"/> for initiating the OAuth device Flow
/// </summary>
/// <returns></returns>
public static Uri OauthDeviceCode()
{
return "login/device/code".FormatUri();
}

/// <summary>
/// Creates the relative <see cref="Uri"/> to request an OAuth access token.
/// </summary>
Expand Down
54 changes: 54 additions & 0 deletions Octokit/Models/Request/OauthDeviceFlowRequest.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
using System.Collections.ObjectModel;
using System.Diagnostics;
using System.Globalization;
using Octokit.Internal;

namespace Octokit
{
/// <summary>
/// Used to create an Oauth device flow initiation request.
/// </summary>
[DebuggerDisplay("{DebuggerDisplay,nq}")]
public class OauthDeviceFlowRequest
: RequestParameters
{
/// <summary>
/// Creates an instance of the OAuth login request with the required parameter.
/// </summary>
/// <param name="clientId">The client Id you received from GitHub when you registered the application.</param>
public OauthDeviceFlowRequest(string clientId)
{
Ensure.ArgumentNotNullOrEmptyString(clientId, nameof(clientId));

ClientId = clientId;
Scopes = new Collection<string>();
}

/// <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>
/// 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.
/// </summary>
/// <remarks>
/// See the <see href="https://developer.github.com/v3/oauth/#scopes">scopes documentation</see> for more
/// information about scopes.
/// </remarks>
[Parameter(Key = "scope")]
public Collection<string> Scopes { get; private set; }

internal string DebuggerDisplay
{
get
{
return string.Format(CultureInfo.InvariantCulture, "ClientId: {0}, Scopes: {1}", ClientId, Scopes);
}
}
}
}
55 changes: 55 additions & 0 deletions Octokit/Models/Request/OauthTokenRequestForDeviceFlow.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
using System.Diagnostics;
using System.Globalization;
using Octokit.Internal;

namespace Octokit
{
/// <summary>
/// Used to create an Oauth login request for the device flow.
/// </summary>
[DebuggerDisplay("{DebuggerDisplay,nq}")]
internal class OauthTokenRequestForDeviceFlow : RequestParameters
{
/// <summary>
/// Creates an instance of the OAuth login request with the required parameter.
/// </summary>
/// <param name="clientId">The client Id you received from GitHub when you registered the application.</param>
/// <param name="deviceCode">The device code you received from the device flow initiation call.</param>
public OauthTokenRequestForDeviceFlow(string clientId, string deviceCode)
{
Ensure.ArgumentNotNullOrEmptyString(clientId, nameof(clientId));
Ensure.ArgumentNotNullOrEmptyString(deviceCode, nameof(deviceCode));

ClientId = clientId;
DeviceCode = deviceCode;
}

/// <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 device code you received from the device flow initiation call.
/// </summary>
[Parameter(Key = "device_code")]
public string DeviceCode { get; private set; }

/// <summary>
/// The authorization grant type.
/// </summary>
[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);
}
}
}
}
Loading