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

I'm attempting to implement a dual authentication scheme and I'm wondering whether it's feasible. If it is possible, could you assist me by pointing out any mistakes I might be making? #1113

Closed
Lucoky opened this issue Jan 17, 2024 · 13 comments

Comments

@Lucoky
Copy link

Lucoky commented Jan 17, 2024

Hello, the logic I'm using works fine in my API, but when I attempt to implement it in my GraphQL project, it fails to function. Here is the code from my Program.cs:

namespace Koble.GraphQL;

using Core;
using Core.Authorization;
using Core.Authorization.ApiKeyAuthorizationSchema;
using Core.Extensions;
using Entity;
using global::GraphQL;
using global::GraphQL.DataLoader;
using global::GraphQL.MicrosoftDI;
using Microsoft.EntityFrameworkCore;
using Stripe;

/// <summary>
/// GraphQL program.
/// </summary>
public class Program
{
    /// <summary>
    /// Main task.
    /// </summary>
    /// <param name="args">Main args.</param>
    public static void Main(string[] args)
    {
        var builder = WebApplication.CreateBuilder(args);

        var configuration = builder.Configuration;

        // Add Cache for GraphQL.
        builder.Services.AddDistributedMemoryCache();

        // Add GraphQl extra services, like dataloader.
        builder.Services.AddSingleton<IDataLoaderContextAccessor, DataLoaderContextAccessor>();
        builder.Services.AddSingleton<DataLoaderDocumentListener>();
        builder.Services.AddSingleton<IDocumentExecuter, DocumentExecuter>();

        // Add GraphQL service, and set all the configuration.
        builder.Services.AddSingleton(services => new Schema(new SelfActivatingServiceProvider(services)))
            .AddGraphQLUpload()
            .AddGraphQL(options =>
                options.ConfigureExecution((opt, next) =>
                    {
                        opt.EnableMetrics = true;
                        opt.ThrowOnUnhandledException = true;
                        opt.MaxParallelExecutionCount = 100;

                        var services = opt.RequestServices;
                        var listener = services.GetRequiredService<DataLoaderDocumentListener>();
                        opt.Listeners.Add(listener);

                        return next(opt);
                    })
                    .AddSystemTextJson()
                    .AddAuthorizationRule());

        // Add DbContextFactory for PSQL Koble database.
        builder.Services.AddDbContextFactory<PsqlKobleContext>(
            options =>
            options.UseNpgsql(configuration.GetConnectionString("PSQLDB_KOBLE")));
        AppContext.SetSwitch("Npgsql.EnableLegacyTimestampBehavior", true);

        // Add Koble.GraphQL settings (environment variables).
        builder.Services.ConfigureEnvironmentSettings<Settings>(configuration);
        builder.Services.Configure<GraphQlSettings>(configuration.GetSection("ConnectionStrings"));
        builder.Services.Configure<GraphQlSettings>(configuration.GetSection("Google"));
        builder.Services.Configure<GraphQlSettings>(configuration.GetSection("GraphQLSettings"));
        builder.Services.Configure<GraphQlSettings>(configuration.GetSection("Sendgrid"));
        builder.Services.Configure<GraphQlSettings>(configuration.GetSection("Twilio"));

        // Add Cors configuration.
        builder.Services.AddCors(options => options.AddDefaultPolicy(policy =>
        {
            policy
                .WithOrigins(new string[]
                {
                    "http://localhost:3000",
                    "http://localhost:3001",
                    "http://localhost:3002",
                    "http://localhost:80",
                    "http://localhost",
                    "http://localhost:5173",
                })
                .AllowAnyHeader()
                .AllowAnyMethod();
        }));

        // Add authentication and authorization.
        builder.Services.AddAuthentication("Bearer")
            .AddOAuth2Introspection(options =>
            {
                options.Authority = configuration.GetSection("GraphQLSettings")["KOBLE_IDENTITY_URL"];
                options.ClientId = "koble_graphql";
                options.ClientSecret = configuration.GetSection("GraphQLSettings")["KOBLE_GRAPHQL_SECRET"];

                options.EnableCaching = true;
                options.CacheDuration = TimeSpan.FromSeconds(30);
            })
            .AddScheme<ApiKeyAuthenticationOptions, ApiKeyHandler>("ApiKey", options => { });

        builder.Services.AddAuthorization(options =>
        {
            options.AddPolicy("ApiKeyPolicy", policy =>
            {
                policy.AddAuthenticationSchemes("ApiKey");
                 policy.RequireAuthenticatedUser();
             });
            options.AddUserStudentPolicies();
            options.AddUserRecruiterPolicies();
            options.AddUserStudentUserRecruiterPolicies();
            options.AddApiKeyAuthorizationPolicy();
        });

        // Add controllers, for SchemaController.
        builder.Services.AddControllers();
        builder.Services.AddHttpContextAccessor();

        // Add Stripe configuration.
        StripeConfiguration.ApiKey = configuration.GetSection("Stripe")["STRIPE_API_KEY"];

        var app = builder.Build();

        app.UseHttpsRedirection();

        app.UseCors();

        app.MapControllers();

        app.UseRouting();
        app.UseAuthentication();
        app.UseAuthorization();

        app.UseGraphQLAltair("/");
        app.UseGraphQLUpload<Schema>().UseGraphQL<Schema>();

        app.Run();
    }
}

I am attempting to implement the apiKey Policy in this manner:

namespace Koble.GraphQL.Resolvers;

using Microsoft.AspNetCore.Http;
using global::GraphQL;
using global::GraphQL.Types;

// TODO: Delete this file after adding the first query extension that use the api key authentication.

/// <summary>
/// Query extension for initializing the add api key test resolver.
/// </summary>
public static class QueryAddApiKeyTestExtension
{
    public static void AddAddApiKeyTestResolvers(this Query query)
    {
        query.Field<StringGraphType>("addApiKeyTest")
            .Description("Add api key test.")
            .AuthorizeWithPolicy("ApiKeyPolicy")
            .Resolve(context =>
            {
                var httpContext = context.UserContext as HttpContext;
                var etst = query.HttpContextAccessor.HttpContext;
                return httpContext?.Request.Headers["api_key"];
            });
    }
}

I have applied this policy in a controller, and it works as expected. The setup in my API's program is quite similar, especially the .AddAuthentication() part, which uses the same code.

Below is the code for my ApiKeyHandler:

namespace Koble.Core.Authorization.ApiKeyAuthorizationSchema;

using System.Security.Claims;
using System.Text.Encodings.Web;
using Entity;
using Microsoft.AspNetCore.Authentication;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Utils;

/// <summary>
/// Defines the koble api key handler.
/// </summary>
public class ApiKeyHandler : AuthenticationHandler<ApiKeyAuthenticationOptions>
{
    private readonly IDbContextFactory<PsqlKobleContext> psqlKobleContext;

    /// <summary>
    /// Initializes a new instance of the <see cref="ApiKeyHandler"/> class.
    /// </summary>
    /// <param name="options">Api key options class.</param>
    /// <param name="logger">Logger instance.</param>
    /// <param name="encoder">Encode url.</param>
    /// <param name="clock">System clock.</param>
    /// <param name="psqlKobleContext">Koble db factory context.</param>
    public ApiKeyHandler(
        IOptionsMonitor<ApiKeyAuthenticationOptions> options,
        ILoggerFactory logger,
        UrlEncoder encoder,
        ISystemClock clock,
        IDbContextFactory<PsqlKobleContext> psqlKobleContext)
        : base(options, logger, encoder, clock)
    {
        this.psqlKobleContext = psqlKobleContext;
    }

    /// <summary>
    /// Handle authenticate async.
    /// </summary>
    /// <returns>A <see cref="Task{AuthenticationResult}"/> representing the result of the asynchronous operation.</returns>
    protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
    {
        var isHeaderName = this.Request.Headers.TryGetValue(this.Options.HeaderName, out var apiKeyValue);
        if (!isHeaderName)
        {
            return AuthenticateResult.Fail("API key was not provided.");
        }

        var apiKey = apiKeyValue.FirstOrDefault();

        if (string.IsNullOrWhiteSpace(apiKey))
        {
            return AuthenticateResult.Fail("API key was not provided.");
        }

        if (!apiKey.StartsWith(this.Options.ApiKeyPrefix, StringComparison.InvariantCulture))
        {
            return AuthenticateResult.Fail("API key format is not valid, it should start with SLT-.");
        }

        var authenticationInfo = await this.GetAuthenticationInfo(apiKey);

        // If the authenticationInfo is null, then return null.
        if (authenticationInfo == null)
        {
            return AuthenticateResult.Fail("API key is not valid.");
        }

        // Create the claims and put them in an identity.
        var claims = new List<Claim>
        {
            new("sub", authenticationInfo.Id.ToString()),
            new("scope", authenticationInfo.Scope),
        };

        var identity = new ClaimsIdentity(claims, this.Scheme.Name);
        var principal = new ClaimsPrincipal(identity);
        var ticket = new AuthenticationTicket(principal, this.Scheme.Name);

        return AuthenticateResult.Success(ticket);
    }

    private async Task<AuthenticationInfo> GetAuthenticationInfo(string apiKey)
    {
        await using var context = await this.psqlKobleContext.CreateDbContextAsync();

        var integrationService = await context.IntegrationServices
            .FirstOrDefaultAsync(x => x.ApiKey == CommonMethods.GetSha256(apiKey));

        if (integrationService != null)
        {
            return new AuthenticationInfo()
            {
                Id = integrationService.IntegrationServiceId,
                Scope = "integration_service",
            };
        }

        return null;
    }

    private class AuthenticationInfo
    {
        public Guid Id { get; set; }

        public string Scope { get; set; }
    }
}

Sorry for the inconvenience, the truth is I've been stuck on this for a couple of days, thank you!

@Shane32
Copy link
Member

Shane32 commented Jan 18, 2024

Where does the UseGraphQLUpload and AddGraphQLUpload methods come from?

@Shane32
Copy link
Member

Shane32 commented Jan 18, 2024

Have you tried the following?

app.UseGraphQLUpload<Schema>().UseGraphQL("/graphql", opts =>
{
    opts.AuthenticationSchemes = ["Bearer", "ApiKey"];
});

@Shane32
Copy link
Member

Shane32 commented Jan 18, 2024

@Shane32
Copy link
Member

Shane32 commented Jan 18, 2024

I'll move this issue to the server repo.

@Shane32 Shane32 transferred this issue from graphql-dotnet/graphql-dotnet Jan 18, 2024
@Shane32
Copy link
Member

Shane32 commented Jan 18, 2024

Adding the schemes to the UseGraphQL call is needed because the authorization rule is based solely on the data within HttpContext.User. Somehow ASP.NET Core examines the target controller/action to determine the authorization scheme needed; GraphQL.NET does not do this. So specifying the schemes beforehand lets GraphQL.NET rebuild the HttpContext.User property based on the specified schemes. Assuming this appears to fix the problem, I would suggest making sure that your custom policy's policy.AddAuthenticationSchemes("ApiKey"); rule is working properly.

@Lucoky
Copy link
Author

Lucoky commented Jan 20, 2024

Thanks for the guidance! I'll give that method a shot and see how it goes.

@Lucoky
Copy link
Author

Lucoky commented Jan 20, 2024

Where does the UseGraphQLUpload and AddGraphQLUpload methods come from?

They come from GraphQL.Upload.AspNetCore. I'm using it to upload files to graphql

@Lucoky
Copy link
Author

Lucoky commented Jan 20, 2024

I just try

Have you tried the following?

app.UseGraphQLUpload<Schema>().UseGraphQL("/graphql", opts =>
{
    opts.AuthenticationSchemes = ["Bearer", "ApiKey"];
});

I've just tried this and it worked perfectly, thank you so much! Is there any way I can support your project?

@Shane32
Copy link
Member

Shane32 commented Jan 20, 2024

You can support the team by contributing via OpenCollective here: https://opencollective.com/graphql-net Any contributions will be greatly appreciated!

You should be aware that while that solution fixed the issue for regular queries, it's not going to work for multipart requests, as the options set in that sample don't apply to requests handled by UseGraphQLUpload. You'd need to either (a) rewrite the upload middleware to support multiple authentication schemes, or (b) write some ASP.NET Core middleware to rewrite the HttpContext.User value before it hits either middleware. Option B is easier...

@Shane32
Copy link
Member

Shane32 commented Jan 20, 2024

@Shane32
Copy link
Member

Shane32 commented Jan 21, 2024

Here's a PR to update the library to support setting supported authentication schemes:

@Shane32
Copy link
Member

Shane32 commented Jan 23, 2024

Here's an update within this PR to support file uploading:

@Shane32
Copy link
Member

Shane32 commented Feb 6, 2024

Update has been released in 7.7.0. You should not need the GraphQL Upload middleware anymore.

@Shane32 Shane32 closed this as completed Feb 6, 2024
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

2 participants