Skip to content

Commit d6653fb

Browse files
authored
Adding explict Function Invoke claim for non-Platform tokens (#9787)
1 parent b34f692 commit d6653fb

File tree

9 files changed

+198
-19
lines changed

9 files changed

+198
-19
lines changed
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
{
2+
"bindings": [
3+
{
4+
"type": "httpTrigger",
5+
"name": "req",
6+
"direction": "in",
7+
"methods": [ "get" ],
8+
"authLevel": "anonymous"
9+
},
10+
{
11+
"type": "http",
12+
"name": "$return",
13+
"direction": "out"
14+
}
15+
]
16+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
using System.Net;
2+
using Microsoft.AspNetCore.Mvc;
3+
using Microsoft.Extensions.Primitives;
4+
5+
public static IActionResult Run(HttpRequest req, TraceWriter log)
6+
{
7+
log.Info("C# HTTP trigger function processed a request.");
8+
9+
if (req.Query.TryGetValue("name", out StringValues value))
10+
{
11+
return new OkObjectResult($"Hello {value.ToString()}");
12+
}
13+
14+
return new BadRequestObjectResult("Please pass a name on the query string or in the request body");
15+
}

src/WebJobs.Script.WebHost/Security/Authentication/Jwt/ScriptJwtBearerExtensions.cs

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -53,10 +53,16 @@ public static AuthenticationBuilder AddScriptJwtBearer(this AuthenticationBuilde
5353
},
5454
OnTokenValidated = c =>
5555
{
56-
c.Principal.AddIdentity(new ClaimsIdentity(new Claim[]
56+
var claims = new List<Claim>
5757
{
5858
new Claim(SecurityConstants.AuthLevelClaimType, AuthorizationLevel.Admin.ToString())
59-
}));
59+
};
60+
if (!string.Equals(c.SecurityToken.Issuer, ScriptConstants.AppServiceCoreUri, StringComparison.OrdinalIgnoreCase))
61+
{
62+
claims.Add(new Claim(SecurityConstants.InvokeClaimType, "true"));
63+
}
64+
65+
c.Principal.AddIdentity(new ClaimsIdentity(claims));
6066

6167
c.Success();
6268

src/WebJobs.Script.WebHost/Security/Authentication/Keys/AuthenticationLevelHandler.cs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,8 @@ protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
5959
{
6060
var claims = new List<Claim>
6161
{
62-
new Claim(SecurityConstants.AuthLevelClaimType, requestAuthorizationLevel.ToString())
62+
new Claim(SecurityConstants.AuthLevelClaimType, requestAuthorizationLevel.ToString()),
63+
new Claim(SecurityConstants.InvokeClaimType, "true")
6364
};
6465

6566
if (!string.IsNullOrEmpty(name))

src/WebJobs.Script.WebHost/Security/Authentication/SecurityConstants.cs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,5 +13,12 @@ public class SecurityConstants
1313
{
1414
public const string AuthLevelClaimType = "http://schemas.microsoft.com/2017/07/functions/claims/authlevel";
1515
public const string AuthLevelKeyNameClaimType = "http://schemas.microsoft.com/2017/07/functions/claims/keyid";
16+
17+
/// <summary>
18+
/// Claim indicating whether a principal is authorized to invoke an http triggered function via a direct http
19+
/// request to the function. Note that this claim will not be required for invocations triggered via the
20+
/// /admin/functions/{function} API.
21+
/// </summary>
22+
public const string InvokeClaimType = "http://schemas.microsoft.com/2017/07/functions/claims/invoke";
1623
}
1724
}

src/WebJobs.Script.WebHost/Security/Authorization/AuthUtility.cs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,5 +58,16 @@ public static bool PrincipalHasAuthLevelClaim(ClaimsPrincipal principal, Authori
5858

5959
return false;
6060
}
61+
62+
public static bool PrincipalHasInvokeClaim(ClaimsPrincipal principal, AuthorizationLevel requiredLevel)
63+
{
64+
// If the required auth level is anonymous, the requirement is met
65+
if (requiredLevel == AuthorizationLevel.Anonymous)
66+
{
67+
return true;
68+
}
69+
70+
return principal.HasClaim(SecurityConstants.InvokeClaimType, "true");
71+
}
6172
}
6273
}

src/WebJobs.Script.WebHost/Security/Authorization/FunctionAuthorizationHandler.cs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,9 @@ public class FunctionAuthorizationHandler : AuthorizationHandler<FunctionAuthori
1313
protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, FunctionAuthorizationRequirement requirement, FunctionDescriptor resource)
1414
{
1515
var httpTrigger = resource.HttpTriggerAttribute;
16-
if (httpTrigger != null && PrincipalHasAuthLevelClaim(context.User, httpTrigger.AuthLevel))
16+
if (httpTrigger != null &&
17+
PrincipalHasInvokeClaim(context.User, httpTrigger.AuthLevel) &&
18+
PrincipalHasAuthLevelClaim(context.User, httpTrigger.AuthLevel))
1719
{
1820
context.Succeed(requirement);
1921
}

test/WebJobs.Script.Tests.Integration/WebHostEndToEnd/SamplesEndToEndTests_CSharp.cs

Lines changed: 110 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,6 @@
2222
using Microsoft.Azure.WebJobs.Script.WebHost.Middleware;
2323
using Microsoft.Azure.WebJobs.Script.WebHost.Models;
2424
using Microsoft.Azure.WebJobs.Script.WebHost.Security;
25-
using Microsoft.Azure.WebJobs.Script.WebHost.Security.Authentication;
2625
using Microsoft.Azure.WebJobs.Script.Workers.Rpc;
2726
using Microsoft.Extensions.DependencyInjection;
2827
using Microsoft.Extensions.Logging;
@@ -153,6 +152,74 @@ public async Task InvokeAdminLevelFunction_WithoutMasterKey_ReturnsUnauthorized(
153152
}
154153
}
155154

155+
[Fact]
156+
public async Task InvokeFunction_RequiresKeyOrNonPlatformToken()
157+
{
158+
// no key presented
159+
string uri = $"api/httptrigger?name=Mathew";
160+
HttpRequestMessage request = new HttpRequestMessage(HttpMethod.Get, uri);
161+
HttpResponseMessage response = await _fixture.Host.HttpClient.SendAsync(request);
162+
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
163+
164+
// required key supplied
165+
request = new HttpRequestMessage(HttpMethod.Get, uri);
166+
string key = await _fixture.Host.GetFunctionSecretAsync("httptrigger");
167+
request.Headers.Add(AuthenticationLevelHandler.FunctionsKeyHeaderName, key);
168+
response = await _fixture.Host.HttpClient.SendAsync(request);
169+
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
170+
171+
// verify that even though a site token grants admin level access to
172+
// host APIs, it can't be used to invoke user functions
173+
using (new TestScopedEnvironmentVariable(EnvironmentSettingNames.WebSiteAuthEncryptionKey, TestHelpers.GenerateKeyHexString()))
174+
{
175+
string swtToken = SimpleWebTokenHelper.CreateToken(DateTime.UtcNow.AddMinutes(2));
176+
177+
// verify the token is valid by invoking an admin API
178+
request = new HttpRequestMessage(HttpMethod.Get, "admin/host/status");
179+
request.Headers.Add(ScriptConstants.SiteRestrictedTokenHeaderName, swtToken);
180+
response = await _fixture.Host.HttpClient.SendAsync(request);
181+
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
182+
183+
// verify it can't be used to invoke non-anonymous user functions
184+
request = new HttpRequestMessage(HttpMethod.Get, uri);
185+
request.Headers.Add(ScriptConstants.SiteRestrictedTokenHeaderName, swtToken);
186+
response = await _fixture.Host.HttpClient.SendAsync(request);
187+
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
188+
}
189+
190+
// verify non-platform JWT token can be used to invoke non-anonymous user functions
191+
string jwtToken = _fixture.Host.GenerateAdminJwtToken();
192+
request = new HttpRequestMessage(HttpMethod.Get, uri);
193+
request.Headers.Add(ScriptConstants.SiteTokenHeaderName, jwtToken);
194+
response = await _fixture.Host.HttpClient.SendAsync(request);
195+
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
196+
197+
// verify platform JWT token can't be used to invoke non-anonymous user functions
198+
jwtToken = _fixture.Host.GenerateAdminJwtToken(issuer: ScriptConstants.AppServiceCoreUri);
199+
request = new HttpRequestMessage(HttpMethod.Get, uri);
200+
request.Headers.Add(ScriptConstants.SiteTokenHeaderName, jwtToken);
201+
response = await _fixture.Host.HttpClient.SendAsync(request);
202+
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
203+
}
204+
205+
[Fact]
206+
public async Task InvokeFunction_AdminInvokeApi_Succeeds()
207+
{
208+
string functionName = "HttpTrigger";
209+
210+
// jwt token with site issuer
211+
var response = await AdminInvokeFunctionAdminToken(functionName, true);
212+
Assert.Equal(HttpStatusCode.Accepted, response.StatusCode);
213+
214+
// jwt token with platform issuer
215+
response = await AdminInvokeFunctionAdminToken(functionName, true, issuer: ScriptConstants.AppServiceCoreUri);
216+
Assert.Equal(HttpStatusCode.Accepted, response.StatusCode);
217+
218+
// swt token
219+
response = await AdminInvokeFunctionAdminToken(functionName, false);
220+
Assert.Equal(HttpStatusCode.Accepted, response.StatusCode);
221+
}
222+
156223
[Fact]
157224
public async Task ExtensionWebHook_Succeeds()
158225
{
@@ -667,7 +734,7 @@ await TestHelpers.RunWithTimeoutAsync(async () =>
667734
await VerifyOfflineResponse(response);
668735

669736
// verify the same thing when invoking via admin api
670-
response = await AdminInvokeFunction(functionName);
737+
response = await AdminInvokeFunctionMasterKey(functionName);
671738
await VerifyOfflineResponse(response);
672739

673740
// bring host back online
@@ -682,7 +749,7 @@ await TestHelpers.RunWithTimeoutAsync(async () =>
682749
await SamplesTestHelpers.InvokeAndValidateHttpTrigger(_fixture, functionName);
683750

684751
// verify the same thing via admin api
685-
response = await AdminInvokeFunction(functionName);
752+
response = await AdminInvokeFunctionMasterKey(functionName);
686753
Assert.Equal(HttpStatusCode.Accepted, response.StatusCode);
687754
}
688755

@@ -724,7 +791,7 @@ public async Task ListFunctions_Succeeds()
724791
var response = await _fixture.Host.HttpClient.SendAsync(request);
725792
var metadata = (await response.Content.ReadAsAsync<IEnumerable<FunctionMetadataResponse>>()).ToArray();
726793

727-
Assert.Equal(18, metadata.Length);
794+
Assert.Equal(19, metadata.Length);
728795
var function = metadata.Single(p => p.Name == "HttpTrigger-CustomRoute");
729796
Assert.Equal("https://somewebsite.azurewebsites.net/api/csharp/products/{category:alpha?}/{id:int?}/{extra?}", function.InvokeUrlTemplate.ToString());
730797

@@ -892,7 +959,7 @@ public async Task HttpTrigger_CorrelationIDsAreLogged()
892959
Assert.Equal(requestId, (string)jo["requestId"]);
893960
Assert.Equal(200, jo["status"]);
894961
var duration = (long)jo["duration"];
895-
Assert.True(duration > 0);
962+
Assert.True(duration >= 0);
896963

897964
// determine the function invocation ID
898965
var scriptHostLogs = _fixture.Host.GetScriptHostLogMessages();
@@ -956,8 +1023,18 @@ public async Task HttpTrigger_Poco_Get_Succeeds()
9561023
}
9571024
}
9581025

1026+
[Fact]
1027+
public async Task HttpTrigger_Anonymous_Get_Succeeds()
1028+
{
1029+
string uri = $"api/httptrigger-anonymouslevel?name=Mathew";
1030+
HttpRequestMessage request = new HttpRequestMessage(HttpMethod.Get, uri);
1031+
1032+
HttpResponseMessage response = await _fixture.Host.HttpClient.SendAsync(request);
1033+
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
1034+
}
1035+
9591036
// invoke a function via the admin invoke api
960-
private async Task<HttpResponseMessage> AdminInvokeFunction(string functionName, string input = null)
1037+
private async Task<HttpResponseMessage> AdminInvokeFunctionMasterKey(string functionName, string input = null)
9611038
{
9621039
string masterKey = await _fixture.Host.GetMasterKeyAsync();
9631040
string uri = $"admin/functions/{functionName}?code={masterKey}";
@@ -971,6 +1048,32 @@ private async Task<HttpResponseMessage> AdminInvokeFunction(string functionName,
9711048
return await _fixture.Host.HttpClient.SendAsync(request);
9721049
}
9731050

1051+
private async Task<HttpResponseMessage> AdminInvokeFunctionAdminToken(string functionName, bool jwt, string input = null, string issuer = null)
1052+
{
1053+
1054+
string uri = $"admin/functions/{functionName}";
1055+
HttpRequestMessage request = new HttpRequestMessage(HttpMethod.Post, uri);
1056+
JObject jo = new JObject
1057+
{
1058+
{ "input", input }
1059+
};
1060+
request.Content = new StringContent(jo.ToString());
1061+
request.Content.Headers.ContentType = new MediaTypeHeaderValue("application/json");
1062+
1063+
if (jwt)
1064+
{
1065+
string jwtToken = _fixture.Host.GenerateAdminJwtToken(issuer: issuer);
1066+
request.Headers.Add(ScriptConstants.SiteTokenHeaderName, jwtToken);
1067+
}
1068+
else
1069+
{
1070+
string swtToken = SimpleWebTokenHelper.CreateToken(DateTime.UtcNow.AddMinutes(2));
1071+
request.Headers.Add(ScriptConstants.SiteRestrictedTokenHeaderName, swtToken);
1072+
}
1073+
1074+
return await _fixture.Host.HttpClient.SendAsync(request);
1075+
}
1076+
9741077
[Fact]
9751078
[Trait(TestTraits.Group, TestTraits.AdminIsolationTests)]
9761079
public async Task HttpTrigger_AdminLevel_AdminIsolationEnabled_Succeeds()
@@ -1310,6 +1413,7 @@ public override void ConfigureScriptHost(IWebJobsBuilder webJobsBuilder)
13101413
o.Functions = new[]
13111414
{
13121415
"HttpTrigger",
1416+
"HttpTrigger-AnonymousLevel",
13131417
"HttpTrigger-AdminLevel",
13141418
"HttpTrigger-Compat",
13151419
"HttpTrigger-CustomRoute",

test/WebJobs.Script.Tests/Security/Authorization/FunctionAuthorizationHandlerTests.cs

Lines changed: 26 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,11 @@
11
// Copyright (c) .NET Foundation. All rights reserved.
22
// Licensed under the MIT License. See License.txt in the project root for license information.
33

4-
using System;
54
using System.Collections.Generic;
6-
using System.IO;
75
using System.Security.Claims;
8-
using System.Text;
96
using System.Threading.Tasks;
107
using Microsoft.AspNetCore.Authorization;
118
using Microsoft.Azure.WebJobs.Extensions.Http;
12-
using Microsoft.Azure.WebJobs.Script.Binding;
139
using Microsoft.Azure.WebJobs.Script.Description;
1410
using Microsoft.Azure.WebJobs.Script.WebHost.Security.Authentication;
1511
using Microsoft.Azure.WebJobs.Script.WebHost.Security.Authorization;
@@ -23,13 +19,28 @@ public class FunctionAuthorizationHandlerTests
2319
[Fact]
2420
public async Task Authorization_WithExpectedAuthLevelMatch_Succeeds()
2521
{
26-
await TestAuthorizationAsync(claimAuthLevel: AuthorizationLevel.Function, requiredFunctionLevel: AuthorizationLevel.Function);
22+
await TestAuthorizationAsync(claimAuthLevel: AuthorizationLevel.Function, requiredFunctionLevel: AuthorizationLevel.Function, invokeClaimValue: "true");
2723
}
2824

2925
[Fact]
3026
public async Task Authorization_WithAdminAuthLevel_Succeeds()
3127
{
32-
await TestAuthorizationAsync(claimAuthLevel: AuthorizationLevel.Admin, requiredFunctionLevel: AuthorizationLevel.Function);
28+
await TestAuthorizationAsync(claimAuthLevel: AuthorizationLevel.Admin, requiredFunctionLevel: AuthorizationLevel.Function, invokeClaimValue: "true");
29+
}
30+
31+
[Theory]
32+
[InlineData(null)]
33+
[InlineData("invalid")]
34+
[InlineData("False")]
35+
public async Task Authorization_WithoutInvokeClaim_Fails(string invokeClaimValue)
36+
{
37+
await TestAuthorizationAsync(claimAuthLevel: AuthorizationLevel.Function, requiredFunctionLevel: AuthorizationLevel.Function, invokeClaimValue: invokeClaimValue, expectSuccess: false);
38+
}
39+
40+
[Fact]
41+
public async Task Authorization_Anonymous_WithoutInvokeClaim_Succeeds()
42+
{
43+
await TestAuthorizationAsync(claimAuthLevel: AuthorizationLevel.Anonymous, requiredFunctionLevel: AuthorizationLevel.Anonymous, invokeClaimValue: null);
3344
}
3445

3546
[Fact]
@@ -38,13 +49,15 @@ public async Task Authorization_WithoutHttpTrigger_Fails()
3849
await TestAuthorizationAsync(
3950
claimAuthLevel: AuthorizationLevel.Function,
4051
requiredFunctionLevel: AuthorizationLevel.Function,
52+
invokeClaimValue: bool.TrueString,
4153
descriptor: new Mock<FunctionDescriptor>(),
4254
expectSuccess: false);
4355
}
4456

4557
private async Task TestAuthorizationAsync(
4658
AuthorizationLevel claimAuthLevel,
4759
AuthorizationLevel requiredFunctionLevel,
60+
string invokeClaimValue,
4861
bool expectSuccess = true,
4962
Mock<FunctionDescriptor> descriptor = null)
5063
{
@@ -54,9 +67,13 @@ private async Task TestAuthorizationAsync(
5467

5568
var requirements = new IAuthorizationRequirement[] { new FunctionAuthorizationRequirement() };
5669
var claims = new List<Claim>
57-
{
58-
new Claim(SecurityConstants.AuthLevelClaimType, claimAuthLevel.ToString())
59-
};
70+
{
71+
new Claim(SecurityConstants.AuthLevelClaimType, claimAuthLevel.ToString())
72+
};
73+
if (invokeClaimValue != null)
74+
{
75+
claims.Add(new Claim(SecurityConstants.InvokeClaimType, invokeClaimValue));
76+
}
6077

6178
var user = new ClaimsPrincipal(new ClaimsIdentity(claims, "Test"));
6279

0 commit comments

Comments
 (0)