Good day Experts,
We have a need to build an integration with the XERO Api via their newest OAuth2 standards with (above mentioned) latest .NET CORE 3.1 in VS.
I've poured over the existing sample base in GitHub for the last 2 days without even reaching any authentication points. This is where I'm currently stuck at: Just getting my app to authenticate.
I've resorted to downloading the above sample directly from GitHub and entering (atleast from what I can see) the only 2 variables one needs to make this work: ClientID and ClientSecret (into appsettings.json). The app is also registered under MyApps in Xero with the correct ClientID and ClientSecret.
My environment is pretty straightforward, as they assume in the sample app: Running this from localhost:5000, and register the same under your MyApps in Xero. Except, they say, register your OAuth2 redirect URLS as
.NET CORE doesn't seem to like that, so I have them as
So when I run this, I am presented with the standard 2 Xero buttons (SignUp & SignIn) that was already declared in the View.
Click SignIn Xero button, which should fire:
[HttpGet]
[Route("signin")]
[Authorize(AuthenticationSchemes = "XeroSignIn")]
public IActionResult SignIn()
{
return RedirectToAction("OutstandingInvoices");
}
But doesn't, (Correctly so) since my user identity is not yet authenticated. This (according to Xero's authentication scheme) takes me to Xero's Identity endpoint. (as inspected via POSTMAN)(https://login.xero.com/identity/connect/authorize containing my ClientID, referral URL and scopes as params)
Problem is, then I keep getting this:
Things I've checked/tried:
- Checked that my appsettings.json is detected and ClientID/Secret loads correctly when requested in Startup.cs
- Updated CallbackPath in Startup.cs to "/signin_oidc"
- Changing scopes
- Injecting my clientID & Secret into the XeroClient at different points to make sure it is persisted.
- Looking up literally every other [Xero-Api] tagged post on S.O.
- Reading the Xero Sample Project README several times over.
At this stage, I should be presented by Xero's login page asking me to sign into my Xero Account, then ask me to authorize the scopes my app has applied for, then redirect back to my app. (atleast the first time).
At bit of a loss at this stage as to what I'm missing.
See Startup.cs
public class Startup
{
public Startup(IConfiguration configuration)
{
Configuration = configuration;
}
public IConfiguration Configuration { get; }
// This method gets called by the runtime. Use this method to add services to the container.
public void ConfigureServices(IServiceCollection services)
{
services.AddHttpClient();
services.TryAddSingleton(new XeroConfiguration
{
ClientId = Configuration["Xero:ClientId"],
ClientSecret = Configuration["Xero:ClientSecret"]
});
services.TryAddSingleton<IXeroClient, XeroClient>();
services.TryAddSingleton<IAccountingApi, AccountingApi>();
services.TryAddSingleton<MemoryTokenStore>();
services.AddAuthentication(options =>
{
options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
options.DefaultChallengeScheme = "XeroSignIn";
})
.AddCookie(options =>
{
options.Cookie.Name = "XeroIdentity";
// Clean up cookies that don't match in local MemoryTokenStore.
// In reality you wouldn't need this, as you'd be storing tokens in a real data store somewhere peripheral, so they won't go missing between restarts
options.Events = new CookieAuthenticationEvents
{
OnValidatePrincipal = async context =>
{
var tokenStore = context.HttpContext.RequestServices.GetService<MemoryTokenStore>();
var token = await tokenStore.GetAccessTokenAsync(context.Principal.XeroUserId());
if (token == null)
{
context.RejectPrincipal();
}
}
};
})
.AddOpenIdConnect("XeroSignIn", options =>
{
options.Authority = "https://identity.xero.com";
options.ClientId = Configuration["Xero:ClientId"];
options.ClientSecret = Configuration["Xero:ClientSecret"];
options.ResponseType = "code";
options.Scope.Clear();
options.Scope.Add("openid");
options.Scope.Add("profile");
options.Scope.Add("email");
options.CallbackPath = "/signin_oidc";
options.Events = new OpenIdConnectEvents
{
OnTokenValidated = OnTokenValidated()
};
})
.AddOpenIdConnect("XeroSignUp", options =>
{
options.Authority = "https://identity.xero.com";
options.ClientId = Configuration["Xero:ClientId"];
options.ClientSecret = Configuration["Xero:ClientSecret"];
options.ResponseType = "code";
options.Scope.Clear();
options.Scope.Add("offline_access");
options.Scope.Add("openid");
options.Scope.Add("profile");
options.Scope.Add("email");
options.Scope.Add("accounting.settings");
options.Scope.Add("accounting.transactions");
options.CallbackPath = "/signin_oidc";
options.Events = new OpenIdConnectEvents
{
OnTokenValidated = OnTokenValidated()
};
});
services.Configure<CookiePolicyOptions>(options =>
{
// This lambda determines whether user consent for non-essential cookies is needed for a given request.
options.CheckConsentNeeded = context => true;
options.MinimumSameSitePolicy = SameSiteMode.None;
});
services.AddMvc().SetCompatibilityVersion(CompatibilityVersion.Version_2_2);
}
private static Func<TokenValidatedContext, Task> OnTokenValidated()
{
return context =>
{
var tokenStore = context.HttpContext.RequestServices.GetService<MemoryTokenStore>();
var token = new XeroOAuth2Token
{
AccessToken = context.TokenEndpointResponse.AccessToken,
RefreshToken = context.TokenEndpointResponse.RefreshToken,
ExpiresAtUtc = DateTime.UtcNow.AddSeconds(Convert.ToDouble(context.TokenEndpointResponse.ExpiresIn))
};
tokenStore.SetToken(context.Principal.XeroUserId(), token);
return Task.CompletedTask;
};
}
// This method gets called by the runtime. Use this method to configure the HTTP request pipeline.
public void Configure(IApplicationBuilder app, IHostingEnvironment env)
{
if (env.IsDevelopment())
{
app.UseDeveloperExceptionPage();
}
else
{
app.UseExceptionHandler("/Home/Error");
}
app.UseStaticFiles();
app.UseCookiePolicy();
app.UseAuthentication();
app.UseMvc(routes =>
{
routes.MapRoute(
name: "default",
template: "{controller=Home}/{action=Index}/{id?}");
});
}
}
} And HomeController.cs
public class HomeController : Controller
{
private readonly MemoryTokenStore _tokenStore;
private readonly IXeroClient _xeroClient;
private readonly IAccountingApi _accountingApi;
public HomeController(MemoryTokenStore tokenStore, IXeroClient xeroClient, IAccountingApi accountingApi)
{
_tokenStore = tokenStore;
_xeroClient = xeroClient;
_accountingApi = accountingApi;
}
[HttpGet]
public IActionResult Index()
{
if (User.Identity.IsAuthenticated)
{
return RedirectToAction("OutstandingInvoices");
}
return View();
}
[HttpGet]
[Authorize]
public async Task<IActionResult> OutstandingInvoices()
{
var token = await _tokenStore.GetAccessTokenAsync(User.XeroUserId());
var connections = await _xeroClient.GetConnectionsAsync(token);
if (!connections.Any())
{
return RedirectToAction("NoTenants");
}
var data = new Dictionary<string, int>();
foreach (var connection in connections)
{
var accessToken = token.AccessToken;
var tenantId = connection.TenantId.ToString();
var organisations = await _accountingApi.GetOrganisationsAsync(accessToken, tenantId);
var organisationName = organisations._Organisations[0].Name;
var outstandingInvoices = await _accountingApi.GetInvoicesAsync(accessToken, tenantId, statuses: new List<string>{"AUTHORISED"}, where: "Type == \"ACCREC\"");
data[organisationName] = outstandingInvoices._Invoices.Count;
}
var model = new OutstandingInvoicesViewModel
{
Name = $"{User.FindFirstValue(ClaimTypes.GivenName)} {User.FindFirstValue(ClaimTypes.Surname)}",
Data = data
};
return View(model);
}
[HttpGet]
[Authorize]
public IActionResult NoTenants()
{
return View();
}
[HttpGet]
public async Task<IActionResult> AddConnection()
{
// Signing out of this client app allows the user to be taken through the Xero Identity connection flow again, allowing more organisations to be connected
// The user will not need to log in again because they're only signed out of our app, not Xero.
await HttpContext.SignOutAsync();
return RedirectToAction("SignUp");
}
[HttpGet]
[Route("signup")]
[Authorize(AuthenticationSchemes = "XeroSignUp")]
public IActionResult SignUp()
{
return RedirectToAction("OutstandingInvoices");
}
[HttpGet]
[Route("signin")]
[Authorize(AuthenticationSchemes = "XeroSignIn")]
public IActionResult SignIn()
{
return RedirectToAction("OutstandingInvoices");
}
[ResponseCache(Duration = 0, Location = ResponseCacheLocation.None, NoStore = true)]
public IActionResult Error()
{
return View(new ErrorViewModel { RequestId = Activity.Current?.Id ?? HttpContext.TraceIdentifier });
}
[HttpPost]
[Route("signin_oidc")]
public IActionResult signin_oidc()
{
return RedirectToAction("OutstandingInvoices");
}
}
Any advice would be greatly appreciated!