Skip to content

Commit ce4735c

Browse files
authored
Adding explict Function Invoke claim for non-Platform tokens (#9819)
1 parent 4b25752 commit ce4735c

File tree

9 files changed

+197
-17
lines changed

9 files changed

+197
-17
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: 109 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,74 @@ public async Task InvokeAdminLevelFunction_WithoutMasterKey_ReturnsUnauthorized(
150150
}
151151
}
152152

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

643711
// verify the same thing when invoking via admin api
644-
response = await AdminInvokeFunction(functionName);
712+
response = await AdminInvokeFunctionMasterKey(functionName);
645713
await VerifyOfflineResponse(response);
646714

647715
// bring host back online
@@ -656,7 +724,7 @@ await TestHelpers.RunWithTimeoutAsync(async () =>
656724
await SamplesTestHelpers.InvokeAndValidateHttpTrigger(_fixture, functionName);
657725

658726
// verify the same thing via admin api
659-
response = await AdminInvokeFunction(functionName);
727+
response = await AdminInvokeFunctionMasterKey(functionName);
660728
Assert.Equal(HttpStatusCode.Accepted, response.StatusCode);
661729
}
662730

@@ -698,7 +766,7 @@ public async Task ListFunctions_Succeeds()
698766
var response = await _fixture.Host.HttpClient.SendAsync(request);
699767
var metadata = (await response.Content.ReadAsAsync<IEnumerable<FunctionMetadataResponse>>()).ToArray();
700768

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

@@ -865,8 +933,18 @@ public async Task HttpTrigger_Poco_Get_Succeeds()
865933
}
866934
}
867935

936+
[Fact]
937+
public async Task HttpTrigger_Anonymous_Get_Succeeds()
938+
{
939+
string uri = $"api/httptrigger-anonymouslevel?name=Mathew";
940+
HttpRequestMessage request = new HttpRequestMessage(HttpMethod.Get, uri);
941+
942+
HttpResponseMessage response = await _fixture.Host.HttpClient.SendAsync(request);
943+
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
944+
}
945+
868946
// invoke a function via the admin invoke api
869-
private async Task<HttpResponseMessage> AdminInvokeFunction(string functionName, string input = null)
947+
private async Task<HttpResponseMessage> AdminInvokeFunctionMasterKey(string functionName, string input = null)
870948
{
871949
string masterKey = await _fixture.Host.GetMasterKeyAsync();
872950
string uri = $"admin/functions/{functionName}?code={masterKey}";
@@ -880,6 +958,32 @@ private async Task<HttpResponseMessage> AdminInvokeFunction(string functionName,
880958
return await _fixture.Host.HttpClient.SendAsync(request);
881959
}
882960

961+
private async Task<HttpResponseMessage> AdminInvokeFunctionAdminToken(string functionName, bool jwt, string input = null, string issuer = null)
962+
{
963+
964+
string uri = $"admin/functions/{functionName}";
965+
HttpRequestMessage request = new HttpRequestMessage(HttpMethod.Post, uri);
966+
JObject jo = new JObject
967+
{
968+
{ "input", input }
969+
};
970+
request.Content = new StringContent(jo.ToString());
971+
request.Content.Headers.ContentType = new MediaTypeHeaderValue("application/json");
972+
973+
if (jwt)
974+
{
975+
string jwtToken = _fixture.Host.GenerateAdminJwtToken(issuer: issuer);
976+
request.Headers.Add(ScriptConstants.SiteTokenHeaderName, jwtToken);
977+
}
978+
else
979+
{
980+
string swtToken = SimpleWebTokenHelper.CreateToken(DateTime.UtcNow.AddMinutes(2));
981+
request.Headers.Add(ScriptConstants.SiteRestrictedTokenHeaderName, swtToken);
982+
}
983+
984+
return await _fixture.Host.HttpClient.SendAsync(request);
985+
}
986+
883987
[Fact]
884988
[Trait(TestTraits.Group, TestTraits.AdminIsolationTests)]
885989
public async Task HttpTrigger_AdminLevel_AdminIsolationEnabled_Succeeds()
@@ -1219,6 +1323,7 @@ public override void ConfigureScriptHost(IWebJobsBuilder webJobsBuilder)
12191323
o.Functions = new[]
12201324
{
12211325
"HttpTrigger",
1326+
"HttpTrigger-AnonymousLevel",
12221327
"HttpTrigger-AdminLevel",
12231328
"HttpTrigger-Compat",
12241329
"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: "true",
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)