Skip to content

Commit

Permalink
[OIDC] Add token API for trading bearer token for API key
Browse files Browse the repository at this point in the history
  • Loading branch information
joelverhagen committed Jan 10, 2025
1 parent 941e1ea commit 76f07f1
Show file tree
Hide file tree
Showing 8 changed files with 521 additions and 10 deletions.
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

0 comments on commit 76f07f1

Please sign in to comment.