Custom AuthProvider not working alongside BasicAuthProvider

I’m having difficulty getting both my custom AuthProvider and the BasicAuthProvider working together at the same time.

I’m trying to authenticate against the properties of another model not UserAuth. I’ve tried to duplicate the logic within BasicAuthProvider but with an extra check on the format of the provided username as this will be a guid not a normal email address.

If I exclude the BasicAuthProvider from the AuthFeature plugin my unit tests for the custom provider pass but understandably the basic auth test fails. Including both providers the custom test fails with the exception using ErrorMessages.InvalidUsernameOrPassword thrown by the CredentialsAuthProvider when TryAuthenticate fails. I’ve also got the CredentialsAuthProvider configured and the tests for this pass in both scenarios.

Is it possible to create and include the auth provider like this? I’ve had a look at the source and there doesn’t appear to be anything in the base constructor needed. Is there something extra I need to do in OnAuthenticated to identify the fact authentication has succeeded? I’ve called the base.OnAuthenticated so I can see that is being hit in the custom provider.

The code for the ClientKeyAuthProvider is;

public class ClientKeyAuthProvider : BasicAuthProvider
{
	public new static string Name = "clientkey";
	public new static string Realm = "/auth/clientkey";

	public ClientKeyAuthProvider()
	{
		Provider = Name;
		AuthRealm = Realm;
	}

	public static bool IsClientUserName(string userName)
	{
		return Guid.TryParse(userName, out var _);
	}

	public static bool IsClientSession(IAuthSession session)
	{
		return IsClientUserName(session.UserAuthName) && session.AuthProvider.Equals(Name) && session.UserAuthId.Equals("0");
	}

	public void ValidateClientKey(ClientKey clientKey)
	{
		if (clientKey == null)
			throw HttpError.NotFound("Client key not found.");

		if (string.IsNullOrEmpty(clientKey.Subdomain) || string.IsNullOrEmpty(clientKey.Id) || !IsClientUserName(clientKey.Id))
			throw HttpError.Forbidden("Client identifiers missing.");

		if (string.IsNullOrEmpty(clientKey.Key))
			throw HttpError.Forbidden("Client key missing.");
	}

	public override bool TryAuthenticate(IServiceBase authService, string userName, string password)
	{
		var clientService = HostContext.TryResolve<ClientService>();

		var client = clientService.GetClient(authService.Request.GetSubdomain());
		if (client == null)
			throw HttpError.Forbidden("Client invalid.");

		var clientKey = new ClientKey
		{
			Subdomain = client.Subdomain,
			Id = string.IsNullOrEmpty(userName) ? client.ClientId : userName,
			Key = password
		};

		ValidateClientKey(clientKey);

		if (!clientService.IsValidClientKey(clientKey))
			throw HttpError.Forbidden("Client key invalid.");

		var session = authService.GetSession();

		session.DisplayName = client.Name;
		session.Email = client.BccEmail;
		session.AuthProvider = Name;
		session.UserAuthName = userName;
		session.UserAuthId = "0";

		return true;
	}

	public override IHttpResult OnAuthenticated(IServiceBase authService, IAuthSession session, IAuthTokens tokens, Dictionary<string, string> authInfo)
	{
		return base.OnAuthenticated(authService, session, tokens, authInfo);
	}

	public override void PreAuthenticate(IRequest req, IResponse res)
	{
		var userAuth = req.GetBasicAuthUserAndPassword();

		if (IsClientUserName(userAuth?.Key) && !string.IsNullOrEmpty(userAuth?.Value))
		{
			//Need to run SessionFeature filter since its not executed before this attribute (Priority -100)			
			SessionFeature.AddSessionIdToRequestFilter(req, res, null); //Required to get req.GetSessionId()

			using (var authService = HostContext.ResolveService<AuthenticateService>(req))
			{
				var response = authService.Post(new Authenticate
				{
					provider = Name,
					UserName = userAuth?.Key,
					Password = userAuth?.Value
				});
			}
		}
	}
}

I’m registering the providers using;

plugins.Add(new AuthFeature(() => new ReaderSession(), 
	new IAuthProvider[] {
			new ClientKeyAuthProvider(),
			new BasicAuthProvider(settings),
			new CredentialsAuthProvider(settings),
	})
	{
		HtmlRedirect = "/",
		IncludeAssignRoleServices = false,
		IncludeRegistrationService = true,
		ValidateUniqueEmails = true
	});

The unit tests I have are are as follows;

[TestMethod]
public void Test_Auth_Basic() 
{
  UserService.CreateCompanyUser(MockData.TestUser, MockData.TestCompany.Id, MockData.TEST_USER_PSWD);

  using(var client = new JsonServiceClient(BaseUrl)) 
	{
    client.SetCredentials(MockData.TestUser.Email, MockData.TEST_USER_PSWD);

    var response = client.Post(new Authenticate());

    Assert.IsNotNull(response.SessionId);
    Assert.AreEqual(MockData.TestUser.DisplayName, response.DisplayName);
    Assert.AreEqual(MockData.TestUser.Email, response.UserName);
    Assert.AreEqual(MockData.TestUser.Id.ToString(), response.UserId);
  }
}

[TestMethod]
public void Test_Auth_ClientKey() 
{
  using(var client = new JsonServiceClient(BaseUrl)) 
	{
    // set header so request location not used
    client.AddHeader(Host.HostConfig.AUTH_DOMAIN_HEADER, MockData.TestCompany.Subdomain);
    client.SetCredentials(MockData.TestCompany.ClientId, MockData.TestCompany.ClientKey);

    var response = client.Post(new Authenticate());

    Assert.IsNotNull(response.SessionId);
    Assert.AreEqual("0", response.UserId);
  }
}

I’ve also tried to explicitly specify the provider in the Authenticate request dto but that didn’t seem to make a difference;

var response = client.Post(new Authenticate() { provider = ClientKeyAuthProvider.Name });

I’m having trouble debugging with SourceLink as I think the release dlls have been optimised so the source doesn’t match and the locals are optimised away. I’ve tried to copy over a compiled debug version to my tests and library projects but they don’t seem to be loaded by Visual Studio.

I’ve been looking at this for a while not and there must be something I’m missing about how the providers work together.

You shouldn’t have 2 of the same Auth Providers registered at the same time, i.e. there should only be 1 Auth Provider that authenticates via HTTP Basic Auth (is or inherits BasicAuthProvider). If you need it to do multiple things, create a single Custom Auth Provider that does everything you want in it. Note your ClientKeyAuthProvider looks very similar to the API Key Auth Provider which you may want to consider instead.

Note that BasicAuthProvider is an IAuthWithRequest Auth Provider where the authentication is included with each request, so you wouldn’t be calling the Authenticate service to Authenticate, you’d just call a Secure Service directly (i.e. requires Authentication) where the Request would be authenticated using the PreAuthenticate handler.

The way BasicAuthProvider typically works is that it first makes an unauthenticated request to any request, if it fails with a http 401 Authorization Required response it will then send the HTTP Basic Auth Headers. You can save the round-trip by always sending the HTTP Basic Auth credentials with:

var client = new JsonServiceClient(BaseUrl) {
    AlwaysSendBasicAuthHeader = true
}

Note: calling Dispose() (e.g. in a using) the JsonServiceClient is an unnecessary NOOP.

I’ve been looking at the ApiKeyAuthProvider as well. I’m trying to provide the equivalent of authenticating a user but for a client entity without the need for linking it with a dummy UserAuth record.

I’d also been trying to support the Bearer token but without being able to get BasicAuth working it didn’t seem important. I’ll need to examine the source for ApiKeyAuthProvider to look at how it combines the two.

Do you have any advice about duplicating the ApiKey feature for another model instead of UserAuth?

Since the assumption is a username without a password is the API key would I need to check for either a user key or a client key in the same AuthProvider?

Does this imply only one extended ApiKeyAuthProvider in the same manner that there should only be one BasicAuthProvider?

You likely don’t want to use ApiKeyAuthProvider then as it’s opinionated in authenticating against a registered User Auth Repository + maintaining different environment API Keys.

But I’d recommend following its approach for how it authenticates an API Key by only looking at the Username with an empty password (e.g. same behavior as Stripe’s API Key) or via Bearer Token: