Skip to content

Commit

Permalink
Merge pull request #1997 from tgstation/GitHubGateway [APIDeploy][GQL…
Browse files Browse the repository at this point in the history
…Deploy][NugetDeploy]

OAuth Gateways
  • Loading branch information
Cyberboss authored Nov 2, 2024
2 parents efd68c2 + d6a8e10 commit 5326596
Show file tree
Hide file tree
Showing 22 changed files with 275 additions and 46 deletions.
7 changes: 5 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -292,7 +292,7 @@ Create an `appsettings.Production.yml` file next to `appsettings.yml`. This will

- `Swarm:UpdateRequiredNodeCount`: Should be set to the total number of servers in your swarm minus 1. Prevents updates from occurring unless the non-controller server count in the swarm is greater than or equal to this value.

- `Security:OAuth:<Provider Name>`: Sets the OAuth client ID and secret for a given `<Provider Name>`. The currently supported providers are `Keycloak`, `GitHub`, `Discord`, `InvisionCommunity` and `TGForums`. Setting these fields to `null` disables logins with the provider, but does not stop users from associating their accounts using the API. Sample Entry:
- `Security:OAuth:<Provider Name>`: Sets the OAuth client ID and secret for a given `<Provider Name>`. The currently supported providers are `Keycloak`, `GitHub`, `Discord`, `InvisionCommunity` and `TGForums`. Setting these fields to `null` disables logins AND gateway auth with the provider, but does not stop users from associating their accounts using the API. Sample Entry:
```yml
Security:
OAuth:
Expand All @@ -301,6 +301,7 @@ Security:
ClientSecret: "..."
RedirectUrl: "..."
ServerUrl: "..."
Gateway: Disabled # Can be one of `Disabled` disallowing gateway auth (default), `Enabled` allowing gateway auth, or `Only` allowing gateway auth and disabling OAuth logins with this provider
UserInformationUrlOverride: "..." # For power users, leave out of configuration for most cases. Not supported by GitHub provider.
```
The following providers use the `RedirectUrl` setting:
Expand All @@ -315,6 +316,8 @@ The following providers use the `ServerUrl` setting:
- Keycloak
- InvisionCommunity

Gateway auth simply allows the users to authenticate with the service using the configuration you provide and have their impersonation token passed back to the client. An example of this is using GitHub gateway auth to allow clients to enumerate pull requests without getting rate limited.

- `Telemetry:DisableVersionReporting`: Prevents you installation and the version you're using from being reported on the source repository's deployments list

- `Telemetry:ServerFriendlyName`: Prevents anonymous TGS version usage statistics from being sent to be displayed on the repository.
Expand Down Expand Up @@ -637,7 +640,7 @@ This functionality has the following prerequisites:
Here are tools for interacting with the TGS web API

- [tgstation-server-webpanel](https://github.com/tgstation/tgstation-server-webpanel): Official client and included with the server. A react web app for using tgstation-server.
- [Tgstation.Server.ControlPanel](https://github.com/tgstation/Tgstation.Server.ControlPanel): Official client. A cross platform GUI for using tgstation-server. Feature complete but lacks OAuth login options.
- [Tgstation.Server.ControlPanel](https://github.com/tgstation/Tgstation.Server.ControlPanel): Deprecated client. A cross platform GUI for using tgstation-server.
- [Tgstation.Server.Client](https://www.nuget.org/packages/Tgstation.Server.Client): A nuget .NET Standard 2.0 TAP based library for communicating with tgstation-server. Feature complete.
- [Tgstation.Server.Api](https://www.nuget.org/packages/Tgstation.Server.Api): A nuget .NET Standard 2.0 library containing API definitions for tgstation-server. Feature complete.

Expand Down
12 changes: 6 additions & 6 deletions build/Version.props
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,13 @@
<!-- Integration tests will ensure they match across the board -->
<Import Project="WebpanelVersion.props" />
<PropertyGroup>
<TgsCoreVersion>6.11.4</TgsCoreVersion>
<TgsConfigVersion>5.3.0</TgsConfigVersion>
<TgsRestVersion>10.10.0</TgsRestVersion>
<TgsGraphQLVersion>0.4.0</TgsGraphQLVersion>
<TgsCoreVersion>6.12.0</TgsCoreVersion>
<TgsConfigVersion>5.4.0</TgsConfigVersion>
<TgsRestVersion>10.11.0</TgsRestVersion>
<TgsGraphQLVersion>0.5.0</TgsGraphQLVersion>
<TgsCommonLibraryVersion>7.0.0</TgsCommonLibraryVersion>
<TgsApiLibraryVersion>16.1.0</TgsApiLibraryVersion>
<TgsClientVersion>19.1.0</TgsClientVersion>
<TgsApiLibraryVersion>16.2.0</TgsApiLibraryVersion>
<TgsClientVersion>19.2.0</TgsClientVersion>
<TgsDmapiVersion>7.3.0</TgsDmapiVersion>
<TgsInteropVersion>5.10.0</TgsInteropVersion>
<TgsHostWatchdogVersion>1.5.0</TgsHostWatchdogVersion>
Expand Down
5 changes: 5 additions & 0 deletions src/Tgstation.Server.Api/Models/OAuthProviderInfo.cs
Original file line number Diff line number Diff line change
Expand Up @@ -22,5 +22,10 @@ public sealed class OAuthProviderInfo
/// </summary>
[ResponseOptions]
public Uri? ServerUrl { get; set; }

/// <summary>
/// If <see langword="true"/> the OAuth provider may only be used for gateway authentication. If <see langword="false"/> the OAuth provider may be used for server logins or gateway authentication. If <see langword="null"/> the OAuth provider may only be used for server logins.
/// </summary>
public bool? GatewayOnly { get; set; }
}
}
13 changes: 13 additions & 0 deletions src/Tgstation.Server.Api/Models/Response/OAuthGatewayResponse.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
namespace Tgstation.Server.Api.Models.Response
{
/// <summary>
/// Success result for an OAuth gateway login attempt.
/// </summary>
public sealed class OAuthGatewayResponse
{
/// <summary>
/// The user's access token for the requested OAuth service.
/// </summary>
public string? AccessCode { get; set; }
}
}
11 changes: 9 additions & 2 deletions src/Tgstation.Server.Host/Authority/ILoginAuthority.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,17 @@ namespace Tgstation.Server.Host.Authority
public interface ILoginAuthority : IAuthority
{
/// <summary>
/// Attempt to login to the server with the current crentials.
/// Attempt to login to the server with the current Basic or OAuth credentials.
/// </summary>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
/// <returns>A <see cref="ValueTask{TResult}"/> resulting in a <see cref="LoginResult"/> and <see cref="Models.User"/> <see cref="AuthorityResponse{TResult}"/>.</returns>
/// <returns>A <see cref="ValueTask{TResult}"/> resulting in a <see cref="LoginResult"/> <see cref="AuthorityResponse{TResult}"/>.</returns>
ValueTask<AuthorityResponse<LoginResult>> AttemptLogin(CancellationToken cancellationToken);

/// <summary>
/// Attempt to login to an OAuth service with the current OAuth credentials.
/// </summary>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
/// <returns>A <see cref="ValueTask{TResult}"/> resulting in an <see cref="OAuthGatewayLoginResult"/> <see cref="AuthorityResponse{TResult}"/>.</returns>
ValueTask<AuthorityResponse<OAuthGatewayLoginResult>> AttemptOAuthGatewayLogin(CancellationToken cancellationToken);
}
}
90 changes: 66 additions & 24 deletions src/Tgstation.Server.Host/Authority/LoginAuthority.cs
Original file line number Diff line number Diff line change
Expand Up @@ -61,9 +61,10 @@ sealed class LoginAuthority : AuthorityBase, ILoginAuthority
/// <summary>
/// Generate an <see cref="AuthorityResponse{TResult}"/> for a given <paramref name="headersException"/>.
/// </summary>
/// <typeparam name="TResult">The <see cref="Type"/> of <see cref="AuthorityResponse{TResult}"/> to generate.</typeparam>
/// <param name="headersException">The <see cref="HeadersException"/> to generate a response for.</param>
/// <returns>A new, errored <see cref="LoginResult"/> <see cref="AuthorityResponse{TResult}"/>.</returns>
static AuthorityResponse<LoginResult> GenerateHeadersExceptionResponse(HeadersException headersException)
static AuthorityResponse<TResult> GenerateHeadersExceptionResponse<TResult>(HeadersException headersException)
=> new(
new ErrorMessageResponse(ErrorCode.BadHeaders)
{
Expand Down Expand Up @@ -135,7 +136,7 @@ public async ValueTask<AuthorityResponse<LoginResult>> AttemptLogin(Cancellation
{
var headers = apiHeadersProvider.ApiHeaders;
if (headers == null)
return GenerateHeadersExceptionResponse(apiHeadersProvider.HeadersException!);
return GenerateHeadersExceptionResponse<LoginResult>(apiHeadersProvider.HeadersException!);

if (headers.IsTokenAuthentication)
return BadRequest<LoginResult>(ErrorCode.TokenWithToken);
Expand All @@ -161,32 +162,14 @@ public async ValueTask<AuthorityResponse<LoginResult>> AttemptLogin(Cancellation
if (oAuthLogin)
{
var oAuthProvider = headers.OAuthProvider!.Value;
string? externalUserId;
try
{
var validator = oAuthProviders
.GetValidator(oAuthProvider);

if (validator == null)
return BadRequest<LoginResult>(ErrorCode.OAuthProviderDisabled);

externalUserId = await validator
.ValidateResponseCode(headers.OAuthCode!, cancellationToken);

Logger.LogTrace("External {oAuthProvider} UID: {externalUserId}", oAuthProvider, externalUserId);
}
catch (Octokit.RateLimitExceededException ex)
{
return RateLimit<LoginResult>(ex);
}

if (externalUserId == null)
return Unauthorized<LoginResult>();
var (errorResponse, oauthResult) = await TryOAuthenticate<LoginResult>(headers, oAuthProvider, true, cancellationToken);
if (errorResponse != null)
return errorResponse;

query = query.Where(
x => x.OAuthConnections!.Any(
y => y.Provider == oAuthProvider
&& y.ExternalUserId == externalUserId));
&& y.ExternalUserId == oauthResult!.Value.UserID));
}
else
{
Expand Down Expand Up @@ -281,6 +264,30 @@ public async ValueTask<AuthorityResponse<LoginResult>> AttemptLogin(Cancellation
}
}

/// <inheritdoc />
public async ValueTask<AuthorityResponse<OAuthGatewayLoginResult>> AttemptOAuthGatewayLogin(CancellationToken cancellationToken)
{
var headers = apiHeadersProvider.ApiHeaders;
if (headers == null)
return GenerateHeadersExceptionResponse<OAuthGatewayLoginResult>(apiHeadersProvider.HeadersException!);

var oAuthProvider = headers.OAuthProvider;
if (!oAuthProvider.HasValue)
return BadRequest<OAuthGatewayLoginResult>(ErrorCode.BadHeaders);

var (errorResponse, oAuthResult) = await TryOAuthenticate<OAuthGatewayLoginResult>(headers, oAuthProvider.Value, false, cancellationToken);
if (errorResponse != null)
return errorResponse;

Logger.LogDebug("Generated {provider} OAuth AccessCode", oAuthProvider.Value);

return new AuthorityResponse<OAuthGatewayLoginResult>(
new OAuthGatewayLoginResult
{
AccessCode = oAuthResult!.Value.AccessCode,
});
}

/// <summary>
/// Add a given <paramref name="systemIdentity"/> to the <see cref="identityCache"/>.
/// </summary>
Expand All @@ -296,5 +303,40 @@ private async ValueTask CacheSystemIdentity(ISystemIdentity systemIdentity, User
identExpiry += TimeSpan.FromSeconds(15);
await identityCache.CacheSystemIdentity(user, systemIdentity!, identExpiry);
}

/// <summary>
/// Attempt OAuth authentication.
/// </summary>
/// <typeparam name="TResult">The <see cref="Type"/> to use for errored <see cref="AuthorityResponse{TResult}"/>s.</typeparam>
/// <param name="headers">The current <see cref="ApiHeaders"/>.</param>
/// <param name="oAuthProvider">The <see cref="OAuthProvider"/> to use.</param>
/// <param name="forLogin">If this is for a server login.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
/// <returns>A <see cref="ValueTask{TResult}"/> resulting in an errored <see cref="AuthorityResponse{TResult}"/> on failure or the result of the call to <see cref="IOAuthValidator.ValidateResponseCode(string, bool, CancellationToken)"/> on success.</returns>
async ValueTask<(AuthorityResponse<TResult>? ErrorResponse, (string? UserID, string AccessCode)? OAuthResult)> TryOAuthenticate<TResult>(ApiHeaders headers, OAuthProvider oAuthProvider, bool forLogin, CancellationToken cancellationToken)
{
(string? UserID, string AccessCode)? oauthResult;
try
{
var validator = oAuthProviders
.GetValidator(oAuthProvider, forLogin);

if (validator == null)
return (BadRequest<TResult>(ErrorCode.OAuthProviderDisabled), null);
oauthResult = await validator
.ValidateResponseCode(headers.OAuthCode!, forLogin, cancellationToken);

Logger.LogTrace("External {oAuthProvider} UID: {externalUserId}", oAuthProvider, oauthResult);
}
catch (Octokit.RateLimitExceededException ex)
{
return (RateLimit<TResult>(ex), null);
}

if (!oauthResult.HasValue)
return (Unauthorized<TResult>(), null);

return (null, OAuthResult: oauthResult);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,11 @@ public abstract class OAuthConfigurationBase
/// </summary>
public string? ClientSecret { get; set; }

/// <summary>
/// If the OAuth setup is only to be used for passing the user's OAuth token to clients.
/// </summary>
public OAuthGatewayStatus? Gateway { get; set; }

/// <summary>
/// Initializes a new instance of the <see cref="OAuthConfigurationBase"/> class.
/// </summary>
Expand All @@ -33,6 +38,7 @@ public OAuthConfigurationBase(OAuthConfigurationBase oAuthConfiguration)
ArgumentNullException.ThrowIfNull(oAuthConfiguration);
ClientId = oAuthConfiguration.ClientId;
ClientSecret = oAuthConfiguration.ClientSecret;
Gateway = oAuthConfiguration.Gateway;
}
}
}
23 changes: 23 additions & 0 deletions src/Tgstation.Server.Host/Configuration/OAuthGatewayStatus.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
namespace Tgstation.Server.Host
{
/// <summary>
/// Status of the OAuth gateway for a provider.
/// </summary>
public enum OAuthGatewayStatus
{
/// <summary>
/// The OAuth Gateway is disabled.
/// </summary>
Disabled,

/// <summary>
/// The OAuth Gateway is enabled.
/// </summary>
Enabled,

/// <summary>
/// The provider may ONLY be used as an OAuth Gateway.
/// </summary>
Only,
}
}
14 changes: 14 additions & 0 deletions src/Tgstation.Server.Host/Controllers/ApiRootController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -188,5 +188,19 @@ public ValueTask<IActionResult> CreateToken(CancellationToken cancellationToken)

return loginAuthority.InvokeTransformable<LoginResult, TokenResponse>(this, authority => authority.AttemptLogin(cancellationToken));
}

/// <summary>
/// Attempt to authenticate a <see cref="User"/> using <see cref="ApiController.ApiHeaders"/>.
/// </summary>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
/// <returns>A <see cref="ValueTask{TResult}"/> resulting in the <see cref="IActionResult"/> of the operation.</returns>
/// <response code="200"><see cref="OAuthGatewayResponse"/> generated successfully.</response>
/// <response code="401">OAuth authentication failed.</response>
/// <response code="429">OAuth authentication failed due to rate limiting.</response>
[HttpPost("oauth_gateway")]
[ProducesResponseType(typeof(OAuthGatewayResponse), 200)]
[ProducesResponseType(typeof(ErrorMessageResponse), 429)]
public ValueTask<IActionResult> CreateOAuthGatewayToken(CancellationToken cancellationToken)
=> loginAuthority.InvokeTransformable<OAuthGatewayLoginResult, OAuthGatewayResponse>(this, authority => authority.AttemptOAuthGatewayLogin(cancellationToken));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
using System;

namespace Tgstation.Server.Host.Extensions
{
/// <summary>
/// Extension methods for <see cref="OAuthGatewayStatus"/>.
/// </summary>
static class OAuthGatewayStatusExtensions
{
/// <summary>
/// Convert a given <paramref name="oAuthGatewayStatus"/> to a <see cref="Nullable{T}"/> <see cref="bool"/> for API usage.
/// </summary>
/// <param name="oAuthGatewayStatus">The <see cref="OAuthGatewayStatus"/> to convert.</param>
/// <returns>The <see cref="Nullable{T}"/> <see cref="bool"/> form of the <paramref name="oAuthGatewayStatus"/>.</returns>
public static bool? ToBoolean(this OAuthGatewayStatus oAuthGatewayStatus)
=> oAuthGatewayStatus switch
{
OAuthGatewayStatus.Disabled => null,
OAuthGatewayStatus.Enabled => false,
OAuthGatewayStatus.Only => true,
_ => throw new InvalidOperationException($"Invalid {nameof(OAuthGatewayStatus)}: {oAuthGatewayStatus}"),
};
}
}
21 changes: 19 additions & 2 deletions src/Tgstation.Server.Host/GraphQL/Mutation.cs
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,11 @@ public sealed class Mutation
public const string GraphQLDescription = "Root Mutation type";

/// <summary>
/// Generate a JWT for authenticating with server. This is the only operation that accepts and required basic authentication.
/// Generate a JWT for authenticating with server. This requires either the Basic authentication or OAuth authentication schemes.
/// </summary>
/// <param name="loginAuthority">The <see cref="IGraphQLAuthorityInvoker{TAuthority}"/> for the <see cref="ILoginAuthority"/>.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
/// <returns>A Bearer token to be used with further communication with the server.</returns>
/// <returns>A <see cref="LoginResult"/>.</returns>
[Error(typeof(ErrorMessageException))]
public ValueTask<LoginResult> Login(
[Service] IGraphQLAuthorityInvoker<ILoginAuthority> loginAuthority,
Expand All @@ -38,5 +38,22 @@ public ValueTask<LoginResult> Login(
return loginAuthority.Invoke<LoginResult, LoginResult>(
authority => authority.AttemptLogin(cancellationToken));
}

/// <summary>
/// Generate an OAuth user token for the requested service. This requires the OAuth authentication scheme.
/// </summary>
/// <param name="loginAuthority">The <see cref="IGraphQLAuthorityInvoker{TAuthority}"/> for the <see cref="ILoginAuthority"/>.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
/// <returns>An <see cref="OAuthGatewayLoginResult"/>.</returns>
[Error(typeof(ErrorMessageException))]
public ValueTask<OAuthGatewayLoginResult> OAuthGateway(
[Service] IGraphQLAuthorityInvoker<ILoginAuthority> loginAuthority,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(loginAuthority);

return loginAuthority.Invoke<OAuthGatewayLoginResult, OAuthGatewayLoginResult>(
authority => authority.AttemptOAuthGatewayLogin(cancellationToken));
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ namespace Tgstation.Server.Host.GraphQL.Mutations.Payloads
public sealed class LoginResult : ILegacyApiTransformable<TokenResponse>
{
/// <summary>
/// The JSON Web Token (JWT) to use as a Bearer token for accessing the server. Contains an expiry time.
/// The JSON Web Token (JWT) to use as a Bearer token for accessing the server at non-login endpoints. Contains an expiry time.
/// </summary>
[GraphQLType<JwtType>]
[GraphQLNonNullType]
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
using HotChocolate;

using Tgstation.Server.Api.Models.Response;
using Tgstation.Server.Host.Models;

namespace Tgstation.Server.Host.GraphQL.Mutations.Payloads
{
/// <summary>
/// Success result for an OAuth gateway login attempt.
/// </summary>
public sealed class OAuthGatewayLoginResult : ILegacyApiTransformable<OAuthGatewayResponse>
{
/// <summary>
/// The user's access token for the requested OAuth service.
/// </summary>
public required string AccessCode { get; init; }

/// <inheritdoc />
[GraphQLIgnore]
public OAuthGatewayResponse ToApi()
=> new()
{
AccessCode = AccessCode,
};
}
}
Loading

0 comments on commit 5326596

Please sign in to comment.