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

[DRAFT] [OIDC 17] Add token API for trading bearer token for API key #10308

Draft
wants to merge 1 commit into
base: dev
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all 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
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// Copyright (c) .NET Foundation. All rights reserved.
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.

namespace NuGetGallery.Authentication
Expand All @@ -8,5 +8,6 @@ public static class AuthenticationTypes
public static readonly string External = "External";
public static readonly string LocalUser = "LocalUser";
public static readonly string ApiKey = "ApiKey";
public static readonly string Federated = "Federated";
}
}
}
10 changes: 8 additions & 2 deletions src/NuGetGallery/App_Start/Routes.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// Copyright (c) .NET Foundation. All rights reserved.
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
using System.Web.Mvc;
using System.Web.Routing;
Expand Down Expand Up @@ -907,6 +907,12 @@ public static void RegisterApiV2Routes(RouteCollection routes)
RouteName.ApiSimulateError,
"api/simulate-error",
new { controller = "Api", action = nameof(ApiController.SimulateError) });

routes.MapRoute(
RouteName.CreateToken,
"api/v2/token",
defaults: new { controller = TokenApiController.ControllerName, action = nameof(TokenApiController.CreateToken) },
constraints: new { httpMethod = new HttpMethodConstraint("POST") });
}
}
}
}
166 changes: 166 additions & 0 deletions src/NuGetGallery/Controllers/TokenApiController.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.

using System;
using System.Collections.Specialized;
using System.Net;
using System.Net.Http.Headers;
using System.Threading.Tasks;
using System.Web.Mvc;
using NuGetGallery.Authentication;
using NuGetGallery.Services.Authentication;

#nullable enable

namespace NuGetGallery
{
public class CreateTokenRequest
{
public string? Username { get; set; }

public string? Token_Type { get; set; }
}

public class TokenApiController : AppController
{
public static readonly string ControllerName = nameof(TokenApiController).Replace("Controller", string.Empty);
private const string JsonContentType = "application/json";
private const string ApiKeyTokenType = "api_key";

private readonly IFederatedCredentialService _federatedCredentialService;

public TokenApiController(IFederatedCredentialService federatedCredentialService)
{
_federatedCredentialService = federatedCredentialService ?? throw new ArgumentNullException(nameof(federatedCredentialService));
}

#pragma warning disable CA3147 // No need to validate Antiforgery Token with API request
[HttpPost]
[ActionName(RouteName.CreateToken)]
[AllowAnonymous] // authentication is handled inside the action
public async Task<JsonResult> CreateToken(CreateTokenRequest request)
#pragma warning restore CA3147 // No need to validate Antiforgery Token with API request
{
if (!TryGetBearerToken(Request.Headers, out var bearerToken, out var errorMessage))
{
return UnauthorizedJson(errorMessage!);
}

if (User.Identity.IsAuthenticated)
{
return UnauthorizedJson("Only Bearer token authentication is accepted.");
}

if (!MediaTypeWithQualityHeaderValue.TryParse(Request.ContentType, out var parsed)
|| !string.Equals(parsed.MediaType, JsonContentType, StringComparison.OrdinalIgnoreCase))
{
return ErrorJson(HttpStatusCode.UnsupportedMediaType, $"The request must have a Content-Type of '{JsonContentType}'.");
}

if (string.IsNullOrWhiteSpace(Request.UserAgent))
{
return ErrorJson(HttpStatusCode.BadRequest, "A User-Agent header is required.");
}

if (string.IsNullOrWhiteSpace(request?.Username))
{
return ErrorJson(HttpStatusCode.BadRequest, "The username property in the request body is required.");
}

if (request?.Token_Type != "api_key")
{
return ErrorJson(HttpStatusCode.BadRequest, $"The token_type property in the request body is required and must set to '{ApiKeyTokenType}'.");
}

var result = await _federatedCredentialService.GenerateApiKeyAsync(request!.Username!, bearerToken!, Request.Headers);

return result.Type switch
{
GenerateApiKeyResultType.BadRequest => ErrorJson(HttpStatusCode.BadRequest, result.UserMessage),
GenerateApiKeyResultType.Unauthorized => UnauthorizedJson(result.UserMessage),
GenerateApiKeyResultType.Created => ApiKeyJson(result),
_ => throw new NotImplementedException($"Unexpected result type: {result.Type}"),
};
}

private const string BearerScheme = "Bearer";
private const string BearerPrefix = $"{BearerScheme} ";
private const string AuthorizationHeaderName = "Authorization";

private JsonResult ApiKeyJson(GenerateApiKeyResult result)
{
return Json(HttpStatusCode.OK, new
{
token_type = ApiKeyTokenType,
expires = result.Expires.ToString("O"),
api_key = result.PlaintextApiKey,
});
}

private JsonResult UnauthorizedJson(string errorMessage)
{
// Add the "Federated" challenge so the other authentication providers (such as the default sign-in) are not triggered.
OwinContext.Authentication.Challenge(AuthenticationTypes.Federated);

Response.Headers["WWW-Authenticate"] = BearerScheme;

return ErrorJson(HttpStatusCode.Unauthorized, errorMessage);
}

private JsonResult ErrorJson(HttpStatusCode status, string errorMessage)
{
// Show the error message in the HTTP reason phrase (status description) for compatibility with NuGet client error "protocol".
// This, and the response body below, could be formalized with https://github.com/NuGet/NuGetGallery/issues/5818
Response.StatusDescription = errorMessage;

return Json(status, new { error = errorMessage });
}

private static bool TryGetBearerToken(NameValueCollection requestHeaders, out string? bearerToken, out string? errorMessage)
{
var authorizationHeaders = requestHeaders.GetValues(AuthorizationHeaderName);
if (authorizationHeaders is null || authorizationHeaders.Length == 0)
{
bearerToken = null;
errorMessage = $"The {AuthorizationHeaderName} header is missing.";
return false;
}

if (authorizationHeaders.Length > 1)
{
bearerToken = null;
errorMessage = $"Only one {AuthorizationHeaderName} header is allowed.";
return false;
}

var authorizationHeader = authorizationHeaders[0];
if (!authorizationHeader.StartsWith(BearerPrefix, StringComparison.OrdinalIgnoreCase))
{
bearerToken = null;
errorMessage = $"The {AuthorizationHeaderName} header value must start with '{BearerPrefix}'.";
return false;
}

const string missingToken = $"The bearer token is missing from the {AuthorizationHeaderName} header.";

if (authorizationHeader.Length <= BearerPrefix.Length)
{
bearerToken = null;
errorMessage = missingToken;
return false;
}

bearerToken = authorizationHeader.Substring(BearerPrefix.Length);
if (string.IsNullOrWhiteSpace(bearerToken))
{
bearerToken = null;
errorMessage = missingToken;
return false;
}

bearerToken = bearerToken.Trim();
errorMessage = null;
return true;
}
}
}
1 change: 1 addition & 0 deletions src/NuGetGallery/NuGetGallery.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,7 @@
<Compile Include="Authentication\AuthDependenciesModule.cs" />
<Compile Include="Controllers\ExperimentsController.cs" />
<Compile Include="Controllers\ManageDeprecationJsonApiController.cs" />
<Compile Include="Controllers\TokenApiController.cs" />
<Compile Include="ExtensionMethods.cs" />
<Compile Include="Extensions\CakeBuildManagerExtensions.cs" />
<Compile Include="Extensions\ImageExtensions.cs" />
Expand Down
5 changes: 3 additions & 2 deletions src/NuGetGallery/RouteName.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// Copyright (c) .NET Foundation. All rights reserved.
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.

namespace NuGetGallery
Expand Down Expand Up @@ -118,5 +118,6 @@ public static class RouteName
public const string PackageRevalidateAction = "PackageRevalidateAction";
public const string PackageRevalidateSymbolsAction = "PackageRevalidateSymbolsAction";
public const string Send2FAFeedback = "Send2FAFeedback";
public const string CreateToken = "CreateToken";
}
}
}
5 changes: 3 additions & 2 deletions tests/NuGetGallery.Facts/Controllers/ControllerTests.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// Copyright (c) .NET Foundation. All rights reserved.
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.

using System;
Expand Down Expand Up @@ -75,6 +75,7 @@ public void AllActionsHaveAntiForgeryTokenIfNotGet()
new ControllerActionRuleException(typeof(ApiController), nameof(ApiController.PublishPackage)),
new ControllerActionRuleException(typeof(ApiController), nameof(ApiController.DeprecatePackage)),
new ControllerActionRuleException(typeof(PackagesController), nameof(PackagesController.DisplayPackage)),
new ControllerActionRuleException(typeof(TokenApiController), nameof(TokenApiController.CreateToken)),
};

// Act
Expand Down Expand Up @@ -198,4 +199,4 @@ private static string DisplayMethodName(MethodInfo m)
return $"{m.DeclaringType.FullName}.{m.Name}";
}
}
}
}
Loading