0

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

http://localhost:5000/signup-oidc

.NET CORE doesn't seem to like that, so I have them as

http://localhost:5000/signup_oidc

So when I run this, I am presented with the standard 2 Xero buttons (SignUp & SignIn) that was already declared in the View. enter image description here

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: Xero Error page

Things I've checked/tried:

  1. Checked that my appsettings.json is detected and ClientID/Secret loads correctly when requested in Startup.cs
  2. Updated CallbackPath in Startup.cs to "/signin_oidc"
  3. Changing scopes
  4. Injecting my clientID & Secret into the XeroClient at different points to make sure it is persisted.
  5. Looking up literally every other [Xero-Api] tagged post on S.O.
  6. 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!

Medismal
  • 421
  • 3
  • 18
  • I am yet to try this personally, but I believe (from reading a post on here, and one in the Xero developer forum) that the callback URL needs to have a closing / on it. – droopsnoot Feb 19 '20 at 12:13
  • @droopsnoot, thanks for the comment. Tried that (closing the callback URL with /) : no-go. Any other ideas? – Medismal Feb 19 '20 at 12:32
  • Unfortunately not, I'm still at the point of trying to start this, but my old VS version won't support the new API. And I've no idea of CS so will need (possibly) to translate to VB before I can even start. I read that others have this working, someone posted a sample bit of code on the forum, have you seen that? – droopsnoot Feb 19 '20 at 12:41
  • You have the closing slash both in your code, and in the part on the dev site where it asks you for the callback URL? – droopsnoot Feb 19 '20 at 12:43
  • Is it anything to do with scope? In your "sign in" scope, you only have openid, profile and email, where your "sign up" scope has many more areas. That seems the wrong way around to me. – droopsnoot Feb 19 '20 at 12:49
  • @droopsnoot Yes, I've changed the callback URL both in code, and on the app configuration on the Xero platform where it is registered. I've also just tested with different scope settings. Basically added everything from Signup to Signin. No go. Still the same "error" as before. I have seen https://devblog.xero.com/getting-started-with-net-xero-oauth2-0-763ba468a916 which might work, I'm just perplexed as to what I'm doing wrong to make Xero's freshest example code (from their own repo) fail. As in, why doesn't this work? – Medismal Feb 19 '20 at 12:56
  • I've considered, whether or not this is because of the type of controller? Web vs API? Problem is, we should not be at the Redirect part of the process yet. First we need to authenticate with Xero (by logging INTO Xero). – Medismal Feb 19 '20 at 12:57
  • No idea, sorry, These are the types of terminology that I am unfamiliar with, along with more each time I read a different article. The devblog article does show some useful stuff though. – droopsnoot Feb 19 '20 at 13:11
  • 1
    Just to confirm - there's two different auth schemes using two different callback urls in the sample; one ending in signin-oidc, and one ending in sign up-oidc. What callback urls do you have registered in Myapps? – MJMortimer Feb 19 '20 at 19:12
  • 1
    Also, in the startup class you've provided, you've got the same callback path for both the signin and signup auth schemes. They need to be different, and both registered in MyApps, for the sample to work in its entirety – MJMortimer Feb 19 '20 at 19:15
  • 1
    @MJMortimer, Thanks for your comment! Turns out 2 fold problem: Incorrect port nums setup under MyApps in Xero, as well as routing issues in the project. I had registered my OAuth2 redirect urls as http://localhost:{WRONGPORTNUM}/signin_oidc instead of http://localhost:{CORRECTPORTNUM}/home/signin_oidc. (Even though, I specified an explicit route decoration above said methods, which worked in-browser) Turns out, if those SignIn_oidc and SignUp_oidc methods are not reachable, your post to Xero fails like mine did. Please post your comments as an answer so I can mark it accordingly. Thanks – Medismal Feb 20 '20 at 09:17

1 Answers1

2

In the sample, there's two different auth schemes using two different callback urls in the sample; one ending in signin-oidc, and one ending in sign up-oidc.

You need to make sure you register both callback urls for the sample to work in its entirety, and as you've discovered, the need be exactly the same in the developer portal as well as in your code, taking extra care to make sure the ports are the same between the running sample and the callback urls that you register.

MJMortimer
  • 865
  • 5
  • 10