Skip to content
Open
Show file tree
Hide file tree
Changes from 7 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
6 changes: 6 additions & 0 deletions DevProxy.Abstractions/Proxy/Http/IProxySession.cs
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,12 @@ public interface IProxySession
/// </summary>
bool HasResponse { get; }

/// <summary>
/// True when the current <see cref="Response"/> was produced by a plugin (for
/// example a mock) rather than received from the origin.
/// </summary>
bool RespondedByPlugin { get; }

/// <summary>
/// Produces a mocked text response and short-circuits the exchange: no
/// request is sent upstream and <see cref="Response"/> becomes non-null. This
Expand Down
1 change: 1 addition & 0 deletions DevProxy.Abstractions/Utils/ProxyUtils.cs
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,7 @@ public static bool IsGraphUrl(Uri uri)
ArgumentNullException.ThrowIfNull(uri);

return uri.Host.StartsWith("graph.microsoft.", StringComparison.OrdinalIgnoreCase) ||
uri.Host.StartsWith("dod-graph.microsoft.", StringComparison.OrdinalIgnoreCase) ||
uri.Host.StartsWith("microsoftgraph.", StringComparison.OrdinalIgnoreCase);
}

Expand Down

Large diffs are not rendered by default.

376 changes: 376 additions & 0 deletions DevProxy.Plugins/Guidance/GraphMissingPermissionsGuidancePlugin.cs

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using System.IdentityModel.Tokens.Jwt;
using System.Net.Http.Json;
using System.Text.Json;

Expand Down Expand Up @@ -312,48 +311,7 @@ private async Task EvaluateMinimalScopesAsync(
private static (GraphPermissionsType type, IEnumerable<string> permissions) GetPermissionsAndType(RequestLog request)
{
var authHeader = request.Context?.Session.Request.Headers.GetFirst("Authorization");
if (authHeader == null)
{
return (GraphPermissionsType.Application, []);
}

var token = authHeader.Value.Replace("Bearer ", string.Empty, StringComparison.OrdinalIgnoreCase);
var tokenChunks = token.Split('.');
if (tokenChunks.Length != 3)
{
return (GraphPermissionsType.Application, []);
}

try
{
var handler = new JwtSecurityTokenHandler();
var jwtSecurityToken = handler.ReadJwtToken(token);

var scopeClaim = jwtSecurityToken.Claims.FirstOrDefault(c => c.Type == "scp");
if (scopeClaim == null)
{
// possibly an application token
// roles is an array so we need to handle it differently
var roles = jwtSecurityToken.Claims
.Where(c => c.Type == "roles")
.Select(c => c.Value);
if (!roles.Any())
{
return (GraphPermissionsType.Application, []);
}
else
{
return (GraphPermissionsType.Application, roles);
}
}
else
{
return (GraphPermissionsType.Delegated, scopeClaim.Value.Split(' '));
}
}
catch
{
return (GraphPermissionsType.Application, []);
}
var (type, permissions) = GraphUtils.GetPermissionsFromAuthorizationHeader(authHeader?.Value);
return (type ?? GraphPermissionsType.Application, permissions);
}
}
68 changes: 68 additions & 0 deletions DevProxy.Plugins/Utils/GraphUtils.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
using DevProxy.Abstractions.Utils;
using DevProxy.Plugins.Models;
using Microsoft.Extensions.Logging;
using System.IdentityModel.Tokens.Jwt;
using System.Net.Http.Json;
using System.Text.Json;
using System.Text.RegularExpressions;
Expand Down Expand Up @@ -118,6 +119,73 @@ internal async Task<IEnumerable<string>> UpdateUserScopesAsync(IEnumerable<strin
return newMinimalScopes;
}

/// <summary>
/// Returns permissions and type (delegated or application) from the bearer token
/// in the specified Authorization header value. Returns null type and no permissions
/// if the header is missing or the token can't be read as a JWT.
/// </summary>
internal static (GraphPermissionsType? Type, IEnumerable<string> Permissions) GetPermissionsFromAuthorizationHeader(string? authorizationHeader)
{
if (string.IsNullOrEmpty(authorizationHeader))
{
return (null, []);
}

var token = authorizationHeader.Replace("Bearer ", string.Empty, StringComparison.OrdinalIgnoreCase).Trim();
if (token.Split('.').Length != 3)
{
return (null, []);
}

try
{
var jwtSecurityToken = new JwtSecurityTokenHandler().ReadJwtToken(token);

var scopeClaim = jwtSecurityToken.Claims.FirstOrDefault(c => c.Type == "scp");
if (scopeClaim is not null)
{
return (GraphPermissionsType.Delegated, scopeClaim.Value.Split(' ', StringSplitOptions.RemoveEmptyEntries));
}

// possibly an application token; roles is an array
var roles = jwtSecurityToken.Claims
.Where(c => c.Type == "roles")
.Select(c => c.Value)
.ToArray();
return (GraphPermissionsType.Application, roles);
}
catch
{
return (null, []);
}
}

internal static string[] GetUserIdentifiersFromAuthorizationHeader(string? authorizationHeader)
{
if (string.IsNullOrEmpty(authorizationHeader))
{
return [];
}

var token = authorizationHeader.Replace("Bearer ", string.Empty, StringComparison.OrdinalIgnoreCase).Trim();
if (token.Split('.').Length != 3)
{
return [];
}

try
{
var jwtSecurityToken = new JwtSecurityTokenHandler().ReadJwtToken(token);
return [.. jwtSecurityToken.Claims
.Where(c => c.Type is "oid" or "upn" or "preferred_username" && !string.IsNullOrEmpty(c.Value))
.Select(c => c.Value)];
}
catch
{
return [];
}
}

internal static string GetTokenizedUrl(string absoluteUrl)
{
absoluteUrl = CanonicalizeDriveItemPathUrl(absoluteUrl);
Expand Down
10 changes: 4 additions & 6 deletions DevProxy.Proxy.Kestrel/Http/CanonicalProxySession.cs
Original file line number Diff line number Diff line change
Expand Up @@ -61,12 +61,10 @@ public CanonicalProxySession(string sessionId, MutableHttpRequest request, int?
/// <inheritdoc />
public bool HasResponse => _response is not null;

/// <summary>
/// True when the current <see cref="Response"/> was produced by a plugin (via
/// <see cref="Respond(string, HttpStatusCode, IEnumerable{IHttpHeader})"/>)
/// rather than received from the origin. The engine uses this to short-circuit
/// upstream forwarding.
/// </summary>
/// <inheritdoc />
/// <remarks>
/// The engine uses this to short-circuit upstream forwarding.
/// </remarks>
public bool RespondedByPlugin { get; private set; }

/// <summary>The concrete response, for engine write-back. Null until set.</summary>
Expand Down
15 changes: 15 additions & 0 deletions DevProxy/config/m365.json
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,21 @@
"https://microsoftgraph.chinacloudapi.cn/beta/*"
]
},
{
"name": "GraphMissingPermissionsGuidancePlugin",
"enabled": true,
"pluginPath": "~appFolder/plugins/DevProxy.Plugins.dll",
"urlsToWatch": [
"https://graph.microsoft.com/v1.0/*",
"https://graph.microsoft.com/beta/*",
"https://graph.microsoft.us/v1.0/*",
"https://graph.microsoft.us/beta/*",
"https://dod-graph.microsoft.us/v1.0/*",
"https://dod-graph.microsoft.us/beta/*",
"https://microsoftgraph.chinacloudapi.cn/v1.0/*",
"https://microsoftgraph.chinacloudapi.cn/beta/*"
]
},
{
"name": "CachingGuidancePlugin",
"enabled": true,
Expand Down
5 changes: 5 additions & 0 deletions DevProxy/config/microsoft-graph.json
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,11 @@
"enabled": true,
"pluginPath": "~appFolder/plugins/DevProxy.Plugins.dll"
},
{
"name": "GraphMissingPermissionsGuidancePlugin",
"enabled": true,
"pluginPath": "~appFolder/plugins/DevProxy.Plugins.dll"
},
{
"name": "GraphRandomErrorPlugin",
"enabled": true,
Expand Down
1 change: 1 addition & 0 deletions skills/dev-proxy/references/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -289,6 +289,7 @@ Or filter by request headers:
|--------|-------------|
| `GraphBetaSupportGuidancePlugin` | Warns when beta endpoints are used |
| `GraphClientRequestIdGuidancePlugin` | Recommends client-request-id header |
| `GraphMissingPermissionsGuidancePlugin` | Explains which permissions are missing when Graph returns 403 Forbidden |
| `GraphConnectorGuidancePlugin` | Graph connector guidance |
| `GraphSdkGuidancePlugin` | Recommends official SDKs |
| `GraphSelectGuidancePlugin` | Warns when $select is missing |
Expand Down
1 change: 1 addition & 0 deletions skills/dev-proxy/references/plugin-catalog.md
Original file line number Diff line number Diff line change
Expand Up @@ -262,6 +262,7 @@ Same Azure config properties as above.
|--------|-------------|
| `GraphBetaSupportGuidancePlugin` | Warns when beta endpoints are used |
| `GraphClientRequestIdGuidancePlugin` | Recommends client-request-id header |
| `GraphMissingPermissionsGuidancePlugin` | Explains which permissions are missing when Graph returns 403 Forbidden |
| `GraphConnectorGuidancePlugin` | Graph connector guidance |
| `GraphSdkGuidancePlugin` | Recommends official SDKs |
| `GraphSelectGuidancePlugin` | Warns when $select is missing |
Expand Down
Loading