Skip to content

Commit

Permalink
Adding explict Function Invoke claim for non-Platform tokens (#9819)
Browse files Browse the repository at this point in the history
  • Loading branch information
mathewc committed Jan 20, 2024
1 parent 4b25752 commit ce4735c
Show file tree
Hide file tree
Showing 9 changed files with 197 additions and 17 deletions.
16 changes: 16 additions & 0 deletions sample/CSharp/HttpTrigger-AnonymousLevel/function.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
{
"bindings": [
{
"type": "httpTrigger",
"name": "req",
"direction": "in",
"methods": [ "get" ],
"authLevel": "anonymous"
},
{
"type": "http",
"name": "$return",
"direction": "out"
}
]
}
15 changes: 15 additions & 0 deletions sample/CSharp/HttpTrigger-AnonymousLevel/run.csx
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
using System.Net;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Primitives;

public static IActionResult Run(HttpRequest req, TraceWriter log)
{
log.Info("C# HTTP trigger function processed a request.");

if (req.Query.TryGetValue("name", out StringValues value))
{
return new OkObjectResult($"Hello {value.ToString()}");
}

return new BadRequestObjectResult("Please pass a name on the query string or in the request body");
}
Original file line number Diff line number Diff line change
Expand Up @@ -53,10 +53,16 @@ public static AuthenticationBuilder AddScriptJwtBearer(this AuthenticationBuilde
},
OnTokenValidated = c =>
{
c.Principal.AddIdentity(new ClaimsIdentity(new Claim[]
var claims = new List<Claim>
{
new Claim(SecurityConstants.AuthLevelClaimType, AuthorizationLevel.Admin.ToString())
}));
};
if (!string.Equals(c.SecurityToken.Issuer, ScriptConstants.AppServiceCoreUri, StringComparison.OrdinalIgnoreCase))
{
claims.Add(new Claim(SecurityConstants.InvokeClaimType, "true"));
}
c.Principal.AddIdentity(new ClaimsIdentity(claims));
c.Success();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,8 @@ protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
{
var claims = new List<Claim>
{
new Claim(SecurityConstants.AuthLevelClaimType, requestAuthorizationLevel.ToString())
new Claim(SecurityConstants.AuthLevelClaimType, requestAuthorizationLevel.ToString()),
new Claim(SecurityConstants.InvokeClaimType, "true")
};

if (!string.IsNullOrEmpty(name))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,5 +13,12 @@ public class SecurityConstants
{
public const string AuthLevelClaimType = "http://schemas.microsoft.com/2017/07/functions/claims/authlevel";
public const string AuthLevelKeyNameClaimType = "http://schemas.microsoft.com/2017/07/functions/claims/keyid";

/// <summary>
/// Claim indicating whether a principal is authorized to invoke an http triggered function via a direct http
/// request to the function. Note that this claim will not be required for invocations triggered via the
/// /admin/functions/{function} API.
/// </summary>
public const string InvokeClaimType = "http://schemas.microsoft.com/2017/07/functions/claims/invoke";
}
}
11 changes: 11 additions & 0 deletions src/WebJobs.Script.WebHost/Security/Authorization/AuthUtility.cs
Original file line number Diff line number Diff line change
Expand Up @@ -58,5 +58,16 @@ public static bool PrincipalHasAuthLevelClaim(ClaimsPrincipal principal, Authori

return false;
}

public static bool PrincipalHasInvokeClaim(ClaimsPrincipal principal, AuthorizationLevel requiredLevel)
{
// If the required auth level is anonymous, the requirement is met
if (requiredLevel == AuthorizationLevel.Anonymous)
{
return true;
}

return principal.HasClaim(SecurityConstants.InvokeClaimType, "true");
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,9 @@ public class FunctionAuthorizationHandler : AuthorizationHandler<FunctionAuthori
protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, FunctionAuthorizationRequirement requirement, FunctionDescriptor resource)
{
var httpTrigger = resource.HttpTriggerAttribute;
if (httpTrigger != null && PrincipalHasAuthLevelClaim(context.User, httpTrigger.AuthLevel))
if (httpTrigger != null &&
PrincipalHasInvokeClaim(context.User, httpTrigger.AuthLevel) &&
PrincipalHasAuthLevelClaim(context.User, httpTrigger.AuthLevel))
{
context.Succeed(requirement);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,74 @@ public async Task InvokeAdminLevelFunction_WithoutMasterKey_ReturnsUnauthorized(
}
}

[Fact]
public async Task InvokeFunction_RequiresKeyOrNonPlatformToken()
{
// no key presented
string uri = $"api/httptrigger?name=Mathew";
HttpRequestMessage request = new HttpRequestMessage(HttpMethod.Get, uri);
HttpResponseMessage response = await _fixture.Host.HttpClient.SendAsync(request);
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);

// required key supplied
request = new HttpRequestMessage(HttpMethod.Get, uri);
string key = await _fixture.Host.GetFunctionSecretAsync("httptrigger");
request.Headers.Add(AuthenticationLevelHandler.FunctionsKeyHeaderName, key);
response = await _fixture.Host.HttpClient.SendAsync(request);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);

// verify that even though a site token grants admin level access to
// host APIs, it can't be used to invoke user functions
using (new TestScopedEnvironmentVariable(EnvironmentSettingNames.WebSiteAuthEncryptionKey, TestHelpers.GenerateKeyHexString()))
{
string swtToken = SimpleWebTokenHelper.CreateToken(DateTime.UtcNow.AddMinutes(2));

// verify the token is valid by invoking an admin API
request = new HttpRequestMessage(HttpMethod.Get, "admin/host/status");
request.Headers.Add(ScriptConstants.SiteRestrictedTokenHeaderName, swtToken);
response = await _fixture.Host.HttpClient.SendAsync(request);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);

// verify it can't be used to invoke non-anonymous user functions
request = new HttpRequestMessage(HttpMethod.Get, uri);
request.Headers.Add(ScriptConstants.SiteRestrictedTokenHeaderName, swtToken);
response = await _fixture.Host.HttpClient.SendAsync(request);
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
}

// verify non-platform JWT token can be used to invoke non-anonymous user functions
string jwtToken = _fixture.Host.GenerateAdminJwtToken();
request = new HttpRequestMessage(HttpMethod.Get, uri);
request.Headers.Add(ScriptConstants.SiteTokenHeaderName, jwtToken);
response = await _fixture.Host.HttpClient.SendAsync(request);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);

// verify platform JWT token can't be used to invoke non-anonymous user functions
jwtToken = _fixture.Host.GenerateAdminJwtToken(issuer: ScriptConstants.AppServiceCoreUri);
request = new HttpRequestMessage(HttpMethod.Get, uri);
request.Headers.Add(ScriptConstants.SiteTokenHeaderName, jwtToken);
response = await _fixture.Host.HttpClient.SendAsync(request);
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
}

[Fact]
public async Task InvokeFunction_AdminInvokeApi_Succeeds()
{
string functionName = "HttpTrigger";

// jwt token with site issuer
var response = await AdminInvokeFunctionAdminToken(functionName, true);
Assert.Equal(HttpStatusCode.Accepted, response.StatusCode);

// jwt token with platform issuer
response = await AdminInvokeFunctionAdminToken(functionName, true, issuer: ScriptConstants.AppServiceCoreUri);
Assert.Equal(HttpStatusCode.Accepted, response.StatusCode);

// swt token
response = await AdminInvokeFunctionAdminToken(functionName, false);
Assert.Equal(HttpStatusCode.Accepted, response.StatusCode);
}

[Fact]
public async Task ExtensionWebHook_Succeeds()
{
Expand Down Expand Up @@ -641,7 +709,7 @@ public async Task SetHostState_Offline_Succeeds()
await VerifyOfflineResponse(response);

// verify the same thing when invoking via admin api
response = await AdminInvokeFunction(functionName);
response = await AdminInvokeFunctionMasterKey(functionName);
await VerifyOfflineResponse(response);

// bring host back online
Expand All @@ -656,7 +724,7 @@ public async Task SetHostState_Offline_Succeeds()
await SamplesTestHelpers.InvokeAndValidateHttpTrigger(_fixture, functionName);

// verify the same thing via admin api
response = await AdminInvokeFunction(functionName);
response = await AdminInvokeFunctionMasterKey(functionName);
Assert.Equal(HttpStatusCode.Accepted, response.StatusCode);
}

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

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

Expand Down Expand Up @@ -865,8 +933,18 @@ public async Task HttpTrigger_Poco_Get_Succeeds()
}
}

[Fact]
public async Task HttpTrigger_Anonymous_Get_Succeeds()
{
string uri = $"api/httptrigger-anonymouslevel?name=Mathew";
HttpRequestMessage request = new HttpRequestMessage(HttpMethod.Get, uri);

HttpResponseMessage response = await _fixture.Host.HttpClient.SendAsync(request);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
}

// invoke a function via the admin invoke api
private async Task<HttpResponseMessage> AdminInvokeFunction(string functionName, string input = null)
private async Task<HttpResponseMessage> AdminInvokeFunctionMasterKey(string functionName, string input = null)
{
string masterKey = await _fixture.Host.GetMasterKeyAsync();
string uri = $"admin/functions/{functionName}?code={masterKey}";
Expand All @@ -880,6 +958,32 @@ private async Task<HttpResponseMessage> AdminInvokeFunction(string functionName,
return await _fixture.Host.HttpClient.SendAsync(request);
}

private async Task<HttpResponseMessage> AdminInvokeFunctionAdminToken(string functionName, bool jwt, string input = null, string issuer = null)
{

string uri = $"admin/functions/{functionName}";
HttpRequestMessage request = new HttpRequestMessage(HttpMethod.Post, uri);
JObject jo = new JObject
{
{ "input", input }
};
request.Content = new StringContent(jo.ToString());
request.Content.Headers.ContentType = new MediaTypeHeaderValue("application/json");

if (jwt)
{
string jwtToken = _fixture.Host.GenerateAdminJwtToken(issuer: issuer);
request.Headers.Add(ScriptConstants.SiteTokenHeaderName, jwtToken);
}
else
{
string swtToken = SimpleWebTokenHelper.CreateToken(DateTime.UtcNow.AddMinutes(2));
request.Headers.Add(ScriptConstants.SiteRestrictedTokenHeaderName, swtToken);
}

return await _fixture.Host.HttpClient.SendAsync(request);
}

[Fact]
[Trait(TestTraits.Group, TestTraits.AdminIsolationTests)]
public async Task HttpTrigger_AdminLevel_AdminIsolationEnabled_Succeeds()
Expand Down Expand Up @@ -1219,6 +1323,7 @@ public override void ConfigureScriptHost(IWebJobsBuilder webJobsBuilder)
o.Functions = new[]
{
"HttpTrigger",
"HttpTrigger-AnonymousLevel",
"HttpTrigger-AdminLevel",
"HttpTrigger-Compat",
"HttpTrigger-CustomRoute",
Expand Down
Original file line number Diff line number Diff line change
@@ -1,15 +1,11 @@
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the MIT License. See License.txt in the project root for license information.

using System;
using System.Collections.Generic;
using System.IO;
using System.Security.Claims;
using System.Text;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authorization;
using Microsoft.Azure.WebJobs.Extensions.Http;
using Microsoft.Azure.WebJobs.Script.Binding;
using Microsoft.Azure.WebJobs.Script.Description;
using Microsoft.Azure.WebJobs.Script.WebHost.Security.Authentication;
using Microsoft.Azure.WebJobs.Script.WebHost.Security.Authorization;
Expand All @@ -23,13 +19,28 @@ public class FunctionAuthorizationHandlerTests
[Fact]
public async Task Authorization_WithExpectedAuthLevelMatch_Succeeds()
{
await TestAuthorizationAsync(claimAuthLevel: AuthorizationLevel.Function, requiredFunctionLevel: AuthorizationLevel.Function);
await TestAuthorizationAsync(claimAuthLevel: AuthorizationLevel.Function, requiredFunctionLevel: AuthorizationLevel.Function, invokeClaimValue: "true");
}

[Fact]
public async Task Authorization_WithAdminAuthLevel_Succeeds()
{
await TestAuthorizationAsync(claimAuthLevel: AuthorizationLevel.Admin, requiredFunctionLevel: AuthorizationLevel.Function);
await TestAuthorizationAsync(claimAuthLevel: AuthorizationLevel.Admin, requiredFunctionLevel: AuthorizationLevel.Function, invokeClaimValue: "true");
}

[Theory]
[InlineData(null)]
[InlineData("invalid")]
[InlineData("False")]
public async Task Authorization_WithoutInvokeClaim_Fails(string invokeClaimValue)
{
await TestAuthorizationAsync(claimAuthLevel: AuthorizationLevel.Function, requiredFunctionLevel: AuthorizationLevel.Function, invokeClaimValue: invokeClaimValue, expectSuccess: false);
}

[Fact]
public async Task Authorization_Anonymous_WithoutInvokeClaim_Succeeds()
{
await TestAuthorizationAsync(claimAuthLevel: AuthorizationLevel.Anonymous, requiredFunctionLevel: AuthorizationLevel.Anonymous, invokeClaimValue: null);
}

[Fact]
Expand All @@ -38,13 +49,15 @@ public async Task Authorization_WithoutHttpTrigger_Fails()
await TestAuthorizationAsync(
claimAuthLevel: AuthorizationLevel.Function,
requiredFunctionLevel: AuthorizationLevel.Function,
invokeClaimValue: "true",
descriptor: new Mock<FunctionDescriptor>(),
expectSuccess: false);
}

private async Task TestAuthorizationAsync(
AuthorizationLevel claimAuthLevel,
AuthorizationLevel requiredFunctionLevel,
string invokeClaimValue,
bool expectSuccess = true,
Mock<FunctionDescriptor> descriptor = null)
{
Expand All @@ -54,9 +67,13 @@ public async Task Authorization_WithoutHttpTrigger_Fails()

var requirements = new IAuthorizationRequirement[] { new FunctionAuthorizationRequirement() };
var claims = new List<Claim>
{
new Claim(SecurityConstants.AuthLevelClaimType, claimAuthLevel.ToString())
};
{
new Claim(SecurityConstants.AuthLevelClaimType, claimAuthLevel.ToString())
};
if (invokeClaimValue != null)
{
claims.Add(new Claim(SecurityConstants.InvokeClaimType, invokeClaimValue));
}

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

Expand Down

0 comments on commit ce4735c

Please sign in to comment.