Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Adding explict Function Invoke claim for non-Platform tokens #9819

Merged
merged 1 commit into from
Jan 20, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 @@ await TestHelpers.RunWithTimeoutAsync(async () =>
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 @@ await TestHelpers.RunWithTimeoutAsync(async () =>
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 @@ private async Task TestAuthorizationAsync(

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
Loading