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 security schemes to the api interface generator #106

Open
Roflincopter opened this issue Aug 14, 2023 · 11 comments
Open

Adding security schemes to the api interface generator #106

Roflincopter opened this issue Aug 14, 2023 · 11 comments
Labels
enhancement New feature, bug fix, or request

Comments

@Roflincopter
Copy link

I'm currently looking at adding security schemes to the Api generator, but am unsure how to handle it. I suspect that it have to be separate functions, or maybe seperate interfaces, for each securityscheme defined one? But I'm unsure what would be the better way forward and wanted to discuss it.

@christianhelle
Copy link
Owner

@Roflincopter first of all, thank you for bringing this up and for your interest in this

To ensure I understand, are you looking into generating code based on security schemes defined in the OpenAPI specification file?

I have only used Refit with bearer token authorization and for this approach, I keep security concerns out of the API itself. What I like about Refit is that they allow the developer to easily customize the instance of the HttpClient used by the generated code.

I mostly work on the backend of systems and what I normally do is implement HttpClient message handlers for acquiring an access token, as a confidential client using a client/secret persisted securely in some arbitrary secret store (e.g. Azure Keyvault), or using the managed identity service and the DefaultAzureCredentials class from Azure.Identity

Refit provides convenient extension methods to IServiceCollection in the Refit.HttpClientFactory library. Configuring the Refit client becomes as simple as:

static IServiceCollection ConfigureApiClient(this IServiceCollection services)
{
    services.AddTransient<ApiAuthenticationHandler>();
    services
        .AddRefitClient<IApiClient>()
        .ConfigureHttpClient(c => c.BaseAddress = GetApiBaseAddress())
        .AddHttpMessageHandler<ApiAuthenticationHandler>();
    return services;
}

where the authentication handler might look something like this:

class ApiAuthenticationHandler : DelegatingHandler
{
    private readonly TokenRequestContext context;

    public ApiAuthenticationHandler()
    {
        context = new TokenRequestContext(new[] { "https://api.foo.com/dev/bar/.default" });
    }

    protected override async Task<HttpResponseMessage> SendAsync(
        HttpRequestMessage request,
        CancellationToken cancellationToken)
    {
        request.Headers.Authorization = await GetTokenAsync(cancellationToken);
        var response = await base.SendAsync(request, cancellationToken);
        return response;
    }

    private async Task<AuthenticationHeaderValue?> GetTokenAsync(CancellationToken cancellationToken)
    {
        var credentials = new DefaultAzureCredential(AzureCredentialOptions.CreateDefault());
        var token = await credentials.GetTokenAsync(context, cancellationToken);
        return new AuthenticationHeaderValue("Bearer", token.Token);
    }
}

In most, if not all cases, I would also configure things like HTTP telemetry logging and retry policies with Polly at the HttpClient level

static IServiceCollection ConfigureApiClient(this IServiceCollection services)
{
    services.AddTransient<ApiAuthenticationHandler>();
    services
        .AddRefitClient<IApiClient>()
        .ConfigureHttpClient(c => c.BaseAddress = GetApiBaseAddress())
        .AddHttpMessageHandler<ApiAuthenticationHandler>()
        .AddHttpMessageHandler<TelemetryDelegatingHandler>()
        .AddPolicyHandler(
            HttpPolicyExtensions
                .HandleTransientHttpError()
                .WaitAndRetryAsync(
                    Backoff.DecorrelatedJitterBackoffV2(
                        TimeSpan.FromSeconds(1),
                        6)))
    return services;
}

Where the telemetry handler might look something like this:

class TelemetryDelegatingHandler : DelegatingHandler
{
    private readonly ITelemetryClient telemetryClient;

    public TelemetryDelegatingHandler(ITelemetryClient telemetryClient)
    {
        this.telemetryClient = telemetryClient;
    }

    protected override async Task<HttpResponseMessage> SendAsync(
        HttpRequestMessage request,
        CancellationToken cancellationToken)
    {
        var response = await base.SendAsync(request, cancellationToken);
        return await WriteTelemetry(request, response);
    }

    private async Task<HttpResponseMessage> WriteTelemetry(
        HttpRequestMessage request,
        HttpResponseMessage response)
    {
        try
        {
            var telemetry = new TraceTelemetry(
                "Outbound HTTP Request",
                !response.IsSuccessStatusCode ? SeverityLevel.Error : SeverityLevel.Verbose);            
            
            // Extract request and response details to trace telemetry properties

            telemetryClient.TrackTrace(telemetry);
        }
        catch (Exception e)
        {
            telemetryClient.TrackException(e);
        }

        return response;
    }
}

Sorry for the excessive explanations and examples. You probably already know all that, but since I can't really know I wrote it anyway

So back to the point, are you interested or looking into generating code that redefines headers in the refit interface? Like this:

[Headers("Authorization: Basic YmFzaWMtYXV0aG9yaXphdGlvbi1pczpnZW5lcmFsbHktYS1iYWQtaWRlYQ==")]
public interface ISomeApi
{
    [Get("/things/{id}")]
    Task GetSomething(string id);
}

or are you perhaps after generating the boilerplate code for configuring the HttpClient that Refit uses, like in the first example?

As for implementation details, your actual inquiry (again, sorry that I digress), I recently introduced the IRefitGeneratorInterface which is designed to have multiple implementations based on the RefitGeneratorSettings and how the code should be generated

If we are to support generating code based on security schemes then implementing IRefitInterfaceGenerator would be the best place to do so. It's of course very important that we respect all the settings configured in the RefitGeneratingSettings instance so that we are not breaking any existing functionality

It might also be interesting to generate boilerplate code and convenience extension methods to IServiceCollection, but that will also need to be configured from settings since not everyone using Refitter is building server systems

Thanks for bringing this up again @Roflincopter

@christianhelle christianhelle added the enhancement New feature, bug fix, or request label Aug 14, 2023
@christianhelle
Copy link
Owner

@all-contributors please add @Roflincopter for ideas

@allcontributors
Copy link
Contributor

@christianhelle

I've put up a pull request to add @Roflincopter! 🎉

@Roflincopter
Copy link
Author

I just saw the Authorization attribute in the refit documentation and thought i would be nice to add it to the Interface generator. But as soon as I started thinking about how to implement it there was no clear approach how to because then you would either have to configure the refit http client with right options, which might not be very clear that you have to do. Or you generate methods with explicit parameters for each security scheme you support which leads to an increase of methods on the interface.

Task PostSomething([Body] object body, [Authorize("Bearer")] string token, CancellationToken cancellationToken = default);

Implementing it with HttpMessageHandlers seems like an alright choice, I could still take a look at adding the "Authorization" attributes to the methods, so you have to option to configure it in the client without writing an HttpMessageHandler for it.

[Headers("Authorization: Bearer")]

But then I would have to detect and emit the right attributes, and I have to make sure I don't break the existing method of writing your own HttpMessageHandler. And I don't have a way to test OAuth authentication methods.

@christianhelle
Copy link
Owner

Have you started on this @Roflincopter ?

I was thinking that it would be cool to have a --use-http-client-factory "[base url]" --azure-client "[scope]" CLI tool argument that generates boilerplate code for setting up the Refit client for a App or API running on Azure, like the examples I posted previously

static IServiceCollection ConfigureApiClient(this IServiceCollection services)
{
    services.AddTransient<AzureAuthenticationHandler >();  // Only generate this if '--azure-client' was specified
    services
        .AddRefitClient<IApiClient>()
        .ConfigureHttpClient(c => c.BaseAddress = "[base url value]")
        .AddHttpMessageHandler<AzureAuthenticationHandler >()  // Only generate this if '--azure-client' was specified
    return services;
}

The message handler should only be generated if --azure-client was specified

class AzureAuthenticationHandler : DelegatingHandler
{
    private readonly TokenRequestContext context;

    public ApiAuthenticationHandler()
    {
        context = new TokenRequestContext(new[] { "[OAuth scope defined from CLI argument]" });
    }

    protected override async Task<HttpResponseMessage> SendAsync(
        HttpRequestMessage request,
        CancellationToken cancellationToken)
    {
        request.Headers.Authorization = await GetTokenAsync(cancellationToken);
        var response = await base.SendAsync(request, cancellationToken);
        return response;
    }

    private async Task<AuthenticationHeaderValue?> GetTokenAsync(CancellationToken cancellationToken)
    {
        var credentials = new DefaultAzureCredential(new DefaultAzureCredentialOptions());
        var token = await credentials.GetTokenAsync(context, cancellationToken);
        return new AuthenticationHeaderValue("Bearer", token.Token);
    }
}

If you haven't already started on something like this, then I can do it myself. But if you already have something similar in the works then let's stick with yours

@Roflincopter
Copy link
Author

Roflincopter commented Aug 18, 2023

Not started yet, still thinking on what I want and how sane it is, also less free time to work on this as I had hoped.

I was rather thinking of using the refitter [Headers("Authorization: x")] mechanism. The way I was thinking of implementing it was as a commandline option for refitter: --usesecurityscheme "jwt-auth" where "jwt-auth" is one of the security schemes available in the openapi specification you are generating for.

Generally you will only use one type of authentication in your project anyways and generally all the authorized calls will support all the of the possible authentication methods. That is an assumption on my side but I think it's a sane assumption.

Then for each method that supports the given security-scheme, I will generate a method with the method level attribute: [Headers("Authorization: x")] where x is the right type for that security scheme, either bearer or basic, etc.

Each method that has no security schemes will not have the attribute,

Each method that has other security schemes but not the one you chose will not be generated? (Will this ever happen?)

Then you can configure the refit library to register a callback for authorization header input. So you can do this without attaching a generic header manipulator/adder class to the http client. But rather some lightweight lambda.

to clariy my last paragraph; an except from the refit github page.

Bearer Authentication
Most APIs need some sort of Authentication. The most common is OAuth Bearer authentication. A header is added to each request of the form: Authorization: Bearer . Refit makes it easy to insert your logic to get the token however your app needs, so you don't have to pass a token into each method.

  1. Add [Headers("Authorization: Bearer")] to the interface or methods which need the token.
  2. Set AuthorizationHeaderValueGetter in the RefitSettings instance. Refit will call your delegate each time it needs to obtain the token, so it's a good idea for your mechanism to cache the token value for some period within the token lifetime.

@christianhelle
Copy link
Owner

Not started yet, still thinking on what I want and how sane it is, also less free time to work on this as I had hoped.

Take your time @Roflincopter. Time is precious and it is the only non-renewable resource we humans have. I just think that its awesome that there is life in this little thing I started

I was rather thinking of using the refitter [Headers("Authorization: x")] mechanism. The way I was thinking of implementing it was as a commandline option for refitter: --usesecurityscheme "jwt-auth" where "jwt-auth" is one of the security schemes available in the openapi specification you are generating for.

Do you mind if we use - as a word separator in CLI arguments? I just think its more readable to see --use-security-scheme than --usesecurityscheme. If you have a better reason then I will welcome it. I have strong opinions but they are weakly held

Generally you will only use one type of authentication in your project anyways and generally all the authorized calls will support all the of the possible authentication methods. That is an assumption on my side but I think it's a sane assumption.

It's normal for clients to use a single authentication method, but its also normal for servers to offer multiple authentication methods, all depending on what sorts of clients are communicating with it. OAuth bearer tokens are nice since you can have claims, roles, scopes, and complex sets of customized claims, to describe what resources the client can access based on the clients identity. OAuth bearer tokens on the other hand requires a secure token service that owns the claims used by the server and grants access to the client. Client Certificates are secure but there is no way elegant way to define to which resources the client can access, at least nothing as elegant as OAuth scopes. Basic Authentication rarely makes sense, except for web hooks where the client has no real ties to the server but was configured to call endpoints on it, for basic authentication it will also make sense to have security on the message level, like HMAC signatures, and have the server to just ignore unknown/invalid/unsigned messages

For each method that supports the given security-scheme, I will generate a method with the method level attribute: [Headers("Authorization: x")] where x is the right type for that security scheme, either bearer or basic, etc.

Each method that has no security schemes will not have the attribute,

I like this 👍

Each method that has other security schemes but not the one you chose will not be generated? (Will this ever happen?)

I can't see a scenario where the same client uses multiple security schemes, but it is common practice to have multiple security schemes to cater to different types of clients. I've built quite a few API's that both have OAuth 2.0 Bearer Tokens and Client Certificates that are used separately by different client systems

You can configure the refit library to register a callback for authorization header input. So you can do this without attaching a generic header manipulator/adder class to the http client. But rather some lightweight lambda.

I had no idea that this was possible!

@jbt00000
Copy link

jbt00000 commented May 2, 2024

Just started using refitter recently. Thanks for this software! That said, is there any love or ETA towards this (no commentary for ~8 months)? Every time I regenerate my bindings, I must manually add the attribute to my secure endpoints (only a subset requires authorization) and I worry that I, or a teammate will miss endpoints.

[Headers("Authorization: Bearer")]

@christianhelle
Copy link
Owner

@jbt00000 The idea died out a bit and I think its because most users figured out that they can configure authorization through HttpClient delegating handlers that you can configure Refit to use using the Refit.HttpClientFactory tooling

I usually have something like this configured:

static IServiceCollection ConfigureApiClient(this IServiceCollection services)
{
    services.AddTransient<AzureAuthenticationHandler >();  // Only generate this if '--azure-client' was specified
    services
        .AddRefitClient<IApiClient>()
        .ConfigureHttpClient(c => c.BaseAddress = "[base url value]")
        .AddHttpMessageHandler<AzureAuthenticationHandler >()  // Only generate this if '--azure-client' was specified
    return services;
}

If you're using .NET Core then Refitter can generate IServiceCollection extension methods by using a .refitter settings file. So by having a settings file like this:

{
  "openApiPath": "./OpenAPI/v3.0/petstore.json",
  "namespace": "Petstore",
  "outputFolder": "GeneratedCode",
  "outputFilename": "SwaggerPetstoreDirect.cs",
  "naming": {    
    "useOpenApiTitle": false,
    "interfaceName": "SwaggerPetstoreDirect"
  },
  "dependencyInjectionSettings": {
    "baseUrl": "https://petstore3.swagger.io/api/v3",
    "usePolly": true,
    "pollyMaxRetryCount": 3,
    "firstBackoffRetryInSeconds": 0.5,
    "httpMessageHandlers": [
        "AzureAuthenticationHandler"
    ]
  },
  "codeGeneratorSettings": {
    "dateType": "System.DateTime",
    "dateTimeType": "System.DateTime",
    "arrayType": "System.Collections.Generic.IList"
  },
  "operationNameGenerator": "default",
  "typeAccessibility": "internal"
}

and using Refitter with the --settings-file argument like this:

refitter --settings-file petstore.refitter

The generated code will contain this extension method:

namespace Petstore
{
    using System;
    using Microsoft.Extensions.DependencyInjection;
    using Polly;
    using Polly.Contrib.WaitAndRetry;
    using Polly.Extensions.Http;

    public static partial class IServiceCollectionExtensions
    {
        public static IServiceCollection ConfigureRefitClients(this IServiceCollection services, Action<IHttpClientBuilder>? builder = default)
        {
            var clientBuilderISwaggerPetstoreDirect = services
                .AddRefitClient<ISwaggerPetstoreDirect>()
                .ConfigureHttpClient(c => c.BaseAddress = new Uri("https://petstore3.swagger.io/api/v3"))
                .AddHttpMessageHandler<AzureAuthenticationHandler>()
                .AddPolicyHandler(
                    HttpPolicyExtensions
                        .HandleTransientHttpError()
                        .WaitAndRetryAsync(
                            Backoff.DecorrelatedJitterBackoffV2(
                                TimeSpan.FromSeconds(0.5),
                                3)));
            builder?.Invoke(clientBuilderISwaggerPetstoreDirect);

            return services;
        }
    }
}

hope this helps @jbt00000

@kmfd3s
Copy link

kmfd3s commented Dec 24, 2024

How about this solution?

internal static class ParameterExtractor
{
    public static IEnumerable<string> GetParameters(
        CSharpOperationModel operationModel,
        OpenApiOperation operation,
        RefitGeneratorSettings settings,
        string dynamicQuerystringParameterType,
        out string? dynamicQuerystringParameters)
    {
        var routeParameters = operationModel.Parameters
            .Where(p => p.Kind == OpenApiParameterKind.Path)
            .Select(p => $"{JoinAttributes(GetAliasAsAttribute(p))}{p.Type} {p.VariableName}")
            .ToList();

        var queryParameters =
            GetQueryParameters(operationModel, settings, dynamicQuerystringParameterType, out dynamicQuerystringParameters);

        var bodyParameters = operationModel.Parameters
            .Where(p => p.Kind == OpenApiParameterKind.Body && !p.IsBinaryBodyParameter)
            .Select(p =>
                $"{JoinAttributes("Body", GetAliasAsAttribute(p))}{GetParameterType(p, settings)} {p.VariableName}")
            .ToList();

        var headerParameters = new List<string>();

        if (settings.GenerateOperationHeaders)
        {
            headerParameters = operationModel.Parameters
                .Where(p => p.Kind == OpenApiParameterKind.Header && p.IsHeader)
                .Select(p =>
                    $"{JoinAttributes($"Header(\"{p.Name}\")")}{GetParameterType(p, settings)} {p.VariableName}")
                .ToList();
        }

        // Add
        if (settings.GenerateAuthenticationHeader)
        {
            var document = operation.Parent.Parent;
            foreach (var securitySchemeName in operationModel.Security.SelectMany(x => x.Keys))
            {
                if (!document.SecurityDefinitions.TryGetValue(securitySchemeName, out var securityScheme))
                {
                    continue;
                }

                if (securityScheme.Type == OpenApiSecuritySchemeType.Http || securityScheme.Type == OpenApiSecuritySchemeType.OAuth2)
                {
                    headerParameters.Add($"[Authorize(\"{securityScheme.Scheme ?? "Bearer"}\")] string bearerToken");
                    break;
                }

                if (securityScheme.Type == OpenApiSecuritySchemeType.ApiKey
                    && securityScheme.In == OpenApiSecurityApiKeyLocation.Header
                    && !operationModel.Parameters.Any(p => p.Kind == OpenApiParameterKind.Header && p.IsHeader && p.Name == securityScheme.Name))
                {
                    headerParameters.Add($"[Header(\"{securityScheme.Name}\")] string apiKey");
                    break;
                }
            }
        }
        ...

@christianhelle
Copy link
Owner

Sorry for the late response @kmfd3s, I went offline for the Christmas holidays

The code snippet looks fine to me. I'm fine with getting it in if the change doesn't break existing features. Feel free to create a pull request and I'll make sure that we get it merged in and released

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
enhancement New feature, bug fix, or request
Projects
None yet
Development

No branches or pull requests

4 participants