Skip to content

Commit def05bc

Browse files
authored
[OIDC 17] Add token API for trading bearer token for API key (#10308)
* [OIDC] Add token API for trading bearer token for API key * Add hard enable/disable switch for the token api controller * Use camelCase like our other JSON APIs
1 parent 630c8af commit def05bc

10 files changed

Lines changed: 566 additions & 16 deletions

File tree

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
// Copyright (c) .NET Foundation. All rights reserved.
1+
// Copyright (c) .NET Foundation. All rights reserved.
22
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
33

44
namespace NuGetGallery.Authentication
@@ -8,5 +8,6 @@ public static class AuthenticationTypes
88
public static readonly string External = "External";
99
public static readonly string LocalUser = "LocalUser";
1010
public static readonly string ApiKey = "ApiKey";
11+
public static readonly string Federated = "Federated";
1112
}
12-
}
13+
}

src/NuGetGallery.Services/Authentication/Federated/FederatedCredentialConfiguration.cs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,12 @@ namespace NuGetGallery.Services.Authentication
1212
{
1313
public interface IFederatedCredentialConfiguration
1414
{
15+
/// <summary>
16+
/// A hard switch to enable the token API. This is set by a deployment-time configuration to reduce the
17+
/// complexity of checking whether the token API is enabled (compared to feature flags).
18+
/// </summary>
19+
bool EnableTokenApi { get; }
20+
1521
/// <summary>
1622
/// The expected audience for the incoming token. This is the "aud" claim and should be specific to the gallery
1723
/// service itself (not shared between multiple services). This is used only for Entra ID token validation.
@@ -35,6 +41,8 @@ public interface IFederatedCredentialConfiguration
3541

3642
public class FederatedCredentialConfiguration : IFederatedCredentialConfiguration
3743
{
44+
public bool EnableTokenApi { get; set; }
45+
3846
public string? EntraIdAudience { get; set; }
3947

4048
public TimeSpan ShortLivedApiKeyDuration { get; set; } = TimeSpan.FromMinutes(15);

src/NuGetGallery/App_Start/Routes.cs

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
// Copyright (c) .NET Foundation. All rights reserved.
1+
// Copyright (c) .NET Foundation. All rights reserved.
22
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
33
using System.Web.Mvc;
44
using System.Web.Routing;
@@ -907,6 +907,12 @@ public static void RegisterApiV2Routes(RouteCollection routes)
907907
RouteName.ApiSimulateError,
908908
"api/simulate-error",
909909
new { controller = "Api", action = nameof(ApiController.SimulateError) });
910+
911+
routes.MapRoute(
912+
RouteName.CreateToken,
913+
"api/v2/token",
914+
defaults: new { controller = TokenApiController.ControllerName, action = nameof(TokenApiController.CreateToken) },
915+
constraints: new { httpMethod = new HttpMethodConstraint("POST") });
910916
}
911917
}
912-
}
918+
}
Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
// Copyright (c) .NET Foundation. All rights reserved.
2+
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
3+
4+
using System;
5+
using System.Collections.Specialized;
6+
using System.Net;
7+
using System.Net.Http.Headers;
8+
using System.Threading.Tasks;
9+
using System.Web.Mvc;
10+
using NuGetGallery.Authentication;
11+
using NuGetGallery.Services.Authentication;
12+
13+
#nullable enable
14+
15+
namespace NuGetGallery
16+
{
17+
public class CreateTokenRequest
18+
{
19+
public string? Username { get; set; }
20+
21+
public string? TokenType { get; set; }
22+
}
23+
24+
public class TokenApiController : AppController
25+
{
26+
public static readonly string ControllerName = nameof(TokenApiController).Replace("Controller", string.Empty);
27+
private const string JsonContentType = "application/json";
28+
private const string ApiKeyTokenType = "ApiKey";
29+
private const string BearerScheme = "Bearer";
30+
private const string BearerPrefix = $"{BearerScheme} ";
31+
private const string AuthorizationHeaderName = "Authorization";
32+
33+
private readonly IFederatedCredentialService _federatedCredentialService;
34+
private readonly IFederatedCredentialConfiguration _configuration;
35+
36+
public TokenApiController(
37+
IFederatedCredentialService federatedCredentialService,
38+
IFederatedCredentialConfiguration configuration)
39+
{
40+
_federatedCredentialService = federatedCredentialService ?? throw new ArgumentNullException(nameof(federatedCredentialService));
41+
_configuration = configuration ?? throw new ArgumentNullException(nameof(configuration));
42+
}
43+
44+
#pragma warning disable CA3147 // No need to validate Antiforgery Token with API request
45+
[HttpPost]
46+
[ActionName(RouteName.CreateToken)]
47+
[AllowAnonymous] // authentication is handled inside the action
48+
public async Task<ActionResult> CreateToken(CreateTokenRequest request)
49+
#pragma warning restore CA3147 // No need to validate Antiforgery Token with API request
50+
{
51+
if (!_configuration.EnableTokenApi)
52+
{
53+
return HttpNotFound();
54+
}
55+
56+
if (!TryGetBearerToken(Request.Headers, out var bearerToken, out var errorMessage))
57+
{
58+
return UnauthorizedJson(errorMessage!);
59+
}
60+
61+
if (User.Identity.IsAuthenticated)
62+
{
63+
return UnauthorizedJson("Only Bearer token authentication is accepted.");
64+
}
65+
66+
if (!MediaTypeWithQualityHeaderValue.TryParse(Request.ContentType, out var parsed)
67+
|| !string.Equals(parsed.MediaType, JsonContentType, StringComparison.OrdinalIgnoreCase))
68+
{
69+
return ErrorJson(HttpStatusCode.UnsupportedMediaType, $"The request must have a Content-Type of '{JsonContentType}'.");
70+
}
71+
72+
if (string.IsNullOrWhiteSpace(Request.UserAgent))
73+
{
74+
return ErrorJson(HttpStatusCode.BadRequest, "A User-Agent header is required.");
75+
}
76+
77+
if (string.IsNullOrWhiteSpace(request?.Username))
78+
{
79+
return ErrorJson(HttpStatusCode.BadRequest, "The username property in the request body is required.");
80+
}
81+
82+
if (request?.TokenType != ApiKeyTokenType)
83+
{
84+
return ErrorJson(HttpStatusCode.BadRequest, $"The tokenType property in the request body is required and must set to '{ApiKeyTokenType}'.");
85+
}
86+
87+
var result = await _federatedCredentialService.GenerateApiKeyAsync(request!.Username!, bearerToken!, Request.Headers);
88+
89+
return result.Type switch
90+
{
91+
GenerateApiKeyResultType.BadRequest => ErrorJson(HttpStatusCode.BadRequest, result.UserMessage),
92+
GenerateApiKeyResultType.Unauthorized => UnauthorizedJson(result.UserMessage),
93+
GenerateApiKeyResultType.Created => ApiKeyJson(result),
94+
_ => throw new NotImplementedException($"Unexpected result type: {result.Type}"),
95+
};
96+
}
97+
98+
private JsonResult ApiKeyJson(GenerateApiKeyResult result)
99+
{
100+
return Json(HttpStatusCode.OK, new
101+
{
102+
tokenType = ApiKeyTokenType,
103+
expires = result.Expires.ToString("O"),
104+
apiKey = result.PlaintextApiKey,
105+
});
106+
}
107+
108+
private JsonResult UnauthorizedJson(string errorMessage)
109+
{
110+
// Add the "Federated" challenge so the other authentication providers (such as the default sign-in) are not triggered.
111+
OwinContext.Authentication.Challenge(AuthenticationTypes.Federated);
112+
113+
Response.Headers["WWW-Authenticate"] = BearerScheme;
114+
115+
return ErrorJson(HttpStatusCode.Unauthorized, errorMessage);
116+
}
117+
118+
private JsonResult ErrorJson(HttpStatusCode status, string errorMessage)
119+
{
120+
// Show the error message in the HTTP reason phrase (status description) for compatibility with NuGet client error "protocol".
121+
// This, and the response body below, could be formalized with https://github.com/NuGet/NuGetGallery/issues/5818
122+
Response.StatusDescription = errorMessage;
123+
124+
return Json(status, new { error = errorMessage });
125+
}
126+
127+
private static bool TryGetBearerToken(NameValueCollection requestHeaders, out string? bearerToken, out string? errorMessage)
128+
{
129+
var authorizationHeaders = requestHeaders.GetValues(AuthorizationHeaderName);
130+
if (authorizationHeaders is null || authorizationHeaders.Length == 0)
131+
{
132+
bearerToken = null;
133+
errorMessage = $"The {AuthorizationHeaderName} header is missing.";
134+
return false;
135+
}
136+
137+
if (authorizationHeaders.Length > 1)
138+
{
139+
bearerToken = null;
140+
errorMessage = $"Only one {AuthorizationHeaderName} header is allowed.";
141+
return false;
142+
}
143+
144+
var authorizationHeader = authorizationHeaders[0];
145+
if (!authorizationHeader.StartsWith(BearerPrefix, StringComparison.OrdinalIgnoreCase))
146+
{
147+
bearerToken = null;
148+
errorMessage = $"The {AuthorizationHeaderName} header value must start with '{BearerPrefix}'.";
149+
return false;
150+
}
151+
152+
const string missingToken = $"The bearer token is missing from the {AuthorizationHeaderName} header.";
153+
154+
if (authorizationHeader.Length <= BearerPrefix.Length)
155+
{
156+
bearerToken = null;
157+
errorMessage = missingToken;
158+
return false;
159+
}
160+
161+
bearerToken = authorizationHeader.Substring(BearerPrefix.Length);
162+
if (string.IsNullOrWhiteSpace(bearerToken))
163+
{
164+
bearerToken = null;
165+
errorMessage = missingToken;
166+
return false;
167+
}
168+
169+
bearerToken = bearerToken.Trim();
170+
errorMessage = null;
171+
return true;
172+
}
173+
}
174+
}

src/NuGetGallery/NuGetGallery.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -235,6 +235,7 @@
235235
<Compile Include="Authentication\AuthDependenciesModule.cs" />
236236
<Compile Include="Controllers\ExperimentsController.cs" />
237237
<Compile Include="Controllers\ManageDeprecationJsonApiController.cs" />
238+
<Compile Include="Controllers\TokenApiController.cs" />
238239
<Compile Include="ExtensionMethods.cs" />
239240
<Compile Include="Extensions\CakeBuildManagerExtensions.cs" />
240241
<Compile Include="Extensions\ImageExtensions.cs" />

src/NuGetGallery/RouteName.cs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
// Copyright (c) .NET Foundation. All rights reserved.
1+
// Copyright (c) .NET Foundation. All rights reserved.
22
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
33

44
namespace NuGetGallery
@@ -118,5 +118,6 @@ public static class RouteName
118118
public const string PackageRevalidateAction = "PackageRevalidateAction";
119119
public const string PackageRevalidateSymbolsAction = "PackageRevalidateSymbolsAction";
120120
public const string Send2FAFeedback = "Send2FAFeedback";
121+
public const string CreateToken = "CreateToken";
121122
}
122-
}
123+
}

src/NuGetGallery/Web.config

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -203,6 +203,8 @@
203203
<add key="PackageDelete.MaximumDownloadsForPackageVersion" value=""/>
204204
<add key="Gallery.BlockLegacyLicenseUrl" value="false"/>
205205
<add key="Gallery.AllowLicenselessPackages" value="true"/>
206+
<add key="FederatedCredential.EnableTokenApi" value="false"/>
207+
<add key="FederatedCredential.EntraIdAudience" value=""/>
206208
<add key="FederatedCredential.ShortLivedApiKeyDuration" value="00:20:00"/>
207209
<add key="FederatedCredential.EntraIdAudience" value=""/>
208210
<!-- *********************** -->
@@ -534,10 +536,10 @@
534536
</system.diagnostics>
535537
<runtime>
536538
<assemblyBinding xmlns="urn:schemas-microsoft-com:asm.v1">
537-
<dependentAssembly>
538-
<assemblyIdentity name="Microsoft.Identity.Client" publicKeyToken="0A613F4DD989E8AE" culture="neutral"/>
539-
<bindingRedirect oldVersion="0.0.0.0-4.67.2.0" newVersion="4.67.2.0"/>
540-
</dependentAssembly>
539+
<dependentAssembly>
540+
<assemblyIdentity name="Microsoft.Identity.Client" publicKeyToken="0A613F4DD989E8AE" culture="neutral"/>
541+
<bindingRedirect oldVersion="0.0.0.0-4.67.2.0" newVersion="4.67.2.0"/>
542+
</dependentAssembly>
541543
<dependentAssembly>
542544
<assemblyIdentity name="Microsoft.Extensions.Caching.Memory" publicKeyToken="ADB9793829DDAE60" culture="neutral"/>
543545
<bindingRedirect oldVersion="0.0.0.0-8.0.0.1" newVersion="8.0.0.1"/>
@@ -757,7 +759,7 @@
757759
<dependentAssembly>
758760
<assemblyIdentity name="AngleSharp" publicKeyToken="e83494dcdc6d31ea" culture="neutral"/>
759761
<bindingRedirect oldVersion="0.0.0.0-0.17.1.0" newVersion="0.17.1.0"/>
760-
</dependentAssembly>
762+
</dependentAssembly>
761763
</assemblyBinding>
762764
</runtime>
763-
</configuration>
765+
</configuration>

tests/NuGetGallery.Facts/Controllers/ControllerTests.cs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
// Copyright (c) .NET Foundation. All rights reserved.
1+
// Copyright (c) .NET Foundation. All rights reserved.
22
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
33

44
using System;
@@ -75,6 +75,7 @@ public void AllActionsHaveAntiForgeryTokenIfNotGet()
7575
new ControllerActionRuleException(typeof(ApiController), nameof(ApiController.PublishPackage)),
7676
new ControllerActionRuleException(typeof(ApiController), nameof(ApiController.DeprecatePackage)),
7777
new ControllerActionRuleException(typeof(PackagesController), nameof(PackagesController.DisplayPackage)),
78+
new ControllerActionRuleException(typeof(TokenApiController), nameof(TokenApiController.CreateToken)),
7879
};
7980

8081
// Act
@@ -198,4 +199,4 @@ private static string DisplayMethodName(MethodInfo m)
198199
return $"{m.DeclaringType.FullName}.{m.Name}";
199200
}
200201
}
201-
}
202+
}

0 commit comments

Comments
 (0)