Microsoft Identity Web: Change Redirect Uri - c#

I am using .net 5, Identity Web Ui to access Microsoft Graph. Where can I configure my Redirect URI?
I need to specify the full Uri, since the generated one from callbackUri is incorrect due to being behind a Load Balancer with SSL offload.
Here is my current ConfigureServices section
services.AddAuthentication(OpenIdConnectDefaults.AuthenticationScheme)
.AddMicrosoftIdentityWebApp(Configuration.GetSection("AzureAd"))
.EnableTokenAcquisitionToCallDownstreamApi(initialScopes)
.AddMicrosoftGraph(Configuration.GetSection("DownstreamApi"))
.AddInMemoryTokenCaches();

I was facing a similar problem with a WebApp exposed only behind a front door, the WebApp had to call a custom downstream WebApi.
My service configuration that worked on my localhost dev machine:
// AzureAdB2C
services
.AddMicrosoftIdentityWebAppAuthentication(
Configuration,
"AzureAdB2C", subscribeToOpenIdConnectMiddlewareDiagnosticsEvents: true)
.EnableTokenAcquisitionToCallDownstreamApi(p =>
{
p.RedirectUri = redUri; // NOT WORKING, WHY?
p.EnablePiiLogging = true;
},
[... an array with my needed scopes]
)
.AddInMemoryTokenCaches();
I tried the AddDownstreamWebApi but did not manage to make it work so I just fetched the needed token with ITokenAcquisition and added it to an HttpClient to make my request.
Then I needed AzureAd/B2C login redirect to the uri with the front door url:
https://example.org/signin-oidc and things broke. I solved it like this:
First of all you have to add this url to your App registration in the azure portal, very important is case sensitive it cares about trailing slashes and I suspect having many urls that point to the very same controller and the order of these have some impact, I just removed everything and kept the bare minimum.
Then in the configure services method:
services.Configure<OpenIdConnectOptions>(OpenIdConnectDefaults.AuthenticationScheme, options =>
{
options.SaveTokens = true; // this saves the token for the downstream api
options.Events = new OpenIdConnectEvents
{
OnRedirectToIdentityProvider = async ctxt =>
{
// Invoked before redirecting to the identity provider to authenticate. This can be used to set ProtocolMessage.State
// that will be persisted through the authentication process. The ProtocolMessage can also be used to add or customize
// parameters sent to the identity provider.
ctxt.ProtocolMessage.RedirectUri = "https://example.org/signin-oidc";
await Task.Yield();
}
};
});
With that the redirect worked, but I entered a loop between the protected page and the AzureB2C login.
After a succesful login and a correct redirect to the signin-oidc controller (created by the Identity.Web package) I was correctly redirected again to the page that started all this authorization thing, but there it did not find the authorization. So I added/modded also this:
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;
options.Secure = CookieSecurePolicy.Always;
});
With this the authorization worked, but I was not able to get the token to call the downstream API, before this redirect thing ITokenAcquisition worked, but now when trying to get the token it throws an exception.
So in my controller/service to get the token I modified and used:
var accessToken = await _contextAccessor.HttpContext
.GetTokenAsync(OpenIdConnectDefaults.AuthenticationScheme, "access_token");
So now with the token I add it to my HttpRequestMessage like this:
request.Headers.Add("Authorization", $"Bearer {accessToken}");
I lived on StackOverflow and microsoft docs for 3 days, I am not sure this is all "recommended" but this worked for me.

I had the same problem running an asp.net application under Google Cloud Run, which terminates the TLS connection. I was getting the error:
AADSTS50011: The reply URL specified in the request does not match the reply URLs configured for the application.
Using fiddler, I examined the request to login.microsoftonline.com and found that the query parameter redirect_uri exactly matched the url I'd configured in the application in Azure except that it started http rather than https.
I initially tried the other answers involving handling the OpenIdConnectEvents event and updating the redirect uri. This fixed the redirect_url parameter in the call to login.microsoftonline.com and it then worked until I added in the graph api. Then I found my site's signin-oidc page would give its own error about the redirect uri not matching. This would then cause it to go into a loop between my site and login.microsoftonline.com repeatedly trying to authenticate until eventually I'd get a login failure.
On further research ASP.net provides middleware to properly handle this scenario. Your SSL load balancer should add the standard header X-Forwarded-Proto with value HTTPS to the request. It should also send the X-Forwarded-For header with the originating IP address which could be useful for debugging, geoip etc.
In your ASP.net application, to configure the middleware:
services.Configure<ForwardedHeadersOptions>(options =>
{
options.ForwardedHeaders =
ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto;
options.KnownNetworks.Clear();
options.KnownProxies.Clear();
});
Then enable the middleware:
app.UseForwardedHeaders();
Importantly, you must include this before the calls to app.UseAuthentication/app.UseAuthorization that depends on it.
Source: https://learn.microsoft.com/en-us/aspnet/core/host-and-deploy/proxy-load-balancer?view=aspnetcore-5.0
If your load balancer doesn't add the X-Forwarded-Proto header and can't be configured to do so then the document above outlines other options.

I was facing with similar issue for 3 days. The below code helped me to get out of the issue.
string[] initialScopes = Configuration.GetValue<string>("CallApi:ScopeForAccessToken")?.Split(' ');
services.AddMicrosoftIdentityWebAppAuthentication(Configuration, "AzureAd")
.EnableTokenAcquisitionToCallDownstreamApi(initialScopes)
.AddInMemoryTokenCaches();
services.AddControllers();
services.AddRazorPages().AddMvcOptions(options =>
{
var policy = new AuthorizationPolicyBuilder()
.RequireAuthenticatedUser().Build();
options.Filters.Add(new AuthorizeFilter(policy));
}).AddMicrosoftIdentityUI();
services.Configure<OpenIdConnectOptions>(OpenIdConnectDefaults.AuthenticationScheme, options =>
{
options.SaveTokens = true; // this saves the token for the downstream api
options.Events = new OpenIdConnectEvents
{
OnRedirectToIdentityProvider = async ctxt =>
{
ctxt.ProtocolMessage.RedirectUri = "https://example.org/signin-oidc";
await Task.Yield();
}
};
});

Related

Why are .NET 6 Application + Application Gateway + Open ID Connect - Path based routing on different app services not working

I have an application gateway set up ("gateway"):
apps.mydomain.com
I have an app service set up ("app"):
my-app-service.azurewebsites.net
The path based rule is set on the listener for on the gateway address above.
/apps/app1/*
The default backend target and settings are set to the root of the gateway address above.
I am using AADS as the authentication store.
Both work correctly independently as I have another route set up on the gateway. I can go to the app service and it will prompt me for credentials, then take me to the index page at the root.
my-app-service.azurewebsites.net/
What I am trying to do is set up a path based rule that routes through the gateway and lands on a path under apps.mydomain.com. For example,
apps.mydomain.com/apps/app1.
I have set up the gateway properly as I can get to a static page. For example,
apps.mydomain.com/apps/app1/somedirectory/mystaticpage.html.
My problem is that when I try to authenticate, I think the signin-oidc is routing the request incorrectly. I am able to authenticate, and it appears to pass back to apps.mydomain.com/apps/app1/signin-oidc and then the middleware passes back to the root. It is authenticating, because when it hits the error page, it shows me as logged in.
I have tried overriding the cookie policy options:
builder.Services.Configure<CookiePolicyOptions>(options =>
{
options.Secure = CookieSecurePolicy.SameAsRequest;
options.MinimumSameSitePolicy = SameSiteMode.None;
//options.HttpOnly = Microsoft.AspNetCore.CookiePolicy.HttpOnlyPolicy.None;
});
I have tried listening to the OnRedirectToIdentityProvider:
builder.Services.Configure<OpenIdConnectOptions>(OpenIdConnectDefaults.AuthenticationScheme, options =>
{
//options.CallbackPath = new PathString("/apps/app1/");
//options.CallbackPath = new PathString("/apps/app1/signin-oidc");
//options.CallbackPath = "/apps/app1/signin-oidc";
options.Events = new OpenIdConnectEvents
{
OnRedirectToIdentityProvider = (context) =>
{
//https://stackoverflow.com/questions/50262561/correlation-failed-in-net-core-asp-net-identity-openid-connect
context.Options.NonceCookie.Path = "https://apps.mydomain.com/apps/app1/signin-oidc";
context.Options.CorrelationCookie.Path = "https://apps.mydomain.com/apps/app1/signin-oidc";
//https://learn.microsoft.com/en-us/azure/frontdoor/front-door-http-headers-protocol#front-door-to-backend
context.ProtocolMessage.RedirectUri = "https://apps.mydomain.com/apps/app1/signin-oidc";
return Task.FromResult(0);
}
};
});
builder.Services.Configure<ForwardedHeadersOptions>(options =>
{
options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto;
options.KnownNetworks.Clear();
options.KnownProxies.Clear();
});
builder.Services.AddAuthorization(options =>
{
// By default, all incoming requests will be authorized according to the default policy.
options.FallbackPolicy = options.DefaultPolicy;
});
My guess is that just setting the otions.CallbackPath should work, but I just get correlation or sorry, we cannot log you in errors when I try that. Not sure if there is an error in the library.
I have spent over a month on and off and engaged MS technical support trying to solve this, but have not been able to get this to work. I can't imagine I am the only one doing this. I know it is in the open ID connect middleware somewhere, but cannot find the correct combination.
This is just a demo project in .NET 6 to get this working correctly. Any code will do. If there is actual working code somewhere that would be great. Just need to get the path based routing with authentication to work.

ASP.Net Core Antiforgery, Angular and IFRAMES

Let's assume you have another website that wants to display your Angular application inside an IFRAME on one of their pages. How do you configure ASP.Net Core Antiforgery to work properly?
I spent a reasonable amount of time trying to piece this together, so this is my attempt to help others trying to figure out how to get anti-forgery tokens working with ASP.Net Core 6 and Angular application being displayed inside an IFRAME on another website.
This assumes you have already configured ASP.Net Core Antiforgery to work with Angular SPA.
ASP.Net Core Antiforgery, Angular and IFRAMES
Let's assume you have another website that wants to display your Angular application inside an IFRAME on one of their pages. You will encounter a couple of issues to get this working. Let's tackle them one at a time.
Refused to display in a frame because it set 'X-Frame-Options' to 'sameorigin'.
By default, when you call service.GetAndStoreTokens(context), the Antiforgery service sets a response header called X-Frame-Options to te value SAMEORIGIN. According to MDN, "The X-Frame-Options HTTP response header can be used to indicate whether or not a browser should be allowed to render a page in a <frame>, <iframe>, <embed> or <object>. Sites can use this to avoid click-jacking attacks, by ensuring that their content is not embedded into other sites."
In order to get past this issue, you need to turn off this behavior when setting up the Antiforgery service:
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddAntiforgery(options =>
{
options.HeaderName = "X-XSRF-TOKEN";
// suppress setting X-Frame-Options to SAMEORIGIN
options.SuppressXFrameOptionsHeader = true;
});
Now your web page displays inside the IFRAME, but none of the POST requests work properly. That's because there are no cookies are being passed to it.
Cookies not being sent into IFRAME
According to this excellent article, "this is a relatively new problem, as up until recently cookies would be sent through cross-site requests. It all changed when the default value for the SameSite cookie attribute was changed by Google Chrome -- introducing new default behavior that prevents these cookies from going through cross-site requests."
If you watch the network traffic, you'll notice that the cookies that come down with you application entry point are marked as SameSite:Strict. This means they will only get sent if the request comes from a client directly connected to your site. They don't get sent to the IFRAME, so your Angular application is unable to read them and send it back in the HEADER of your API request.
To resolve this problem, you need to fix both of your cookies. Both of them need to be updated to SameSite:None (cookie can be sent to any site) and Secure:true (can only be delivered over HTTPS), which will allow the cookie to flow into the IFRAME.
First, update the Antiforgery service to fix the properties on the default cookie:
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddAntiforgery(options =>
{
options.HeaderName = "X-XSRF-TOKEN";
// suppress setting X-Frame-Options to SAMEORIGIN
options.SuppressXFrameOptionsHeader = true;
// allow cookie to be sent to IFRAME
options.Cookie.SameSite = SameSiteMode.None;
options.Cookie.SecurePolicy = CookieSecurePolicy.Always;
});
Then update the cookie being sent to the Angular application:
var app = builder.Build();
app.MapControllers();
var service = app.Services.GetRequiredService<IAntiforgery>();
app.Use(async (context, next) =>
{
var path = context.Request.Path;
if (path.Equals("/default.html", StringComparison.CurrentCultureIgnoreCase))
{
// generate .AspNetCore.Antiforgery authentication cookie
var tokenSet = service.GetAndStoreTokens(context);
var token = tokenSet.RequestToken;
// duplicate the .AspNetCore.Antiforgery authentication and create a cookie called XSRF-TOKEN
if (token != null)
{
context.Response.Cookies.Append("XSRF-TOKEN", token, new CookieOptions
{
Path = "/",
HttpOnly = false,
// allow cookie to be sent to IFRAME
SameSite = SameSiteMode.None,
Secure = true
});
}
}
await next(context);
});
At this point, the application should display properly in the IFRAME and be able to interact with the Web API. I hope this helps!

How to configure the OAuth callback to a different domain in ASP.NET Core authentication

I am Authenticating against an OAuth endpoint where I can only configure 1 callback domain. (and localhost is whitelisted).
I have my web app running in Azure (myapp.azurewebsites.net) and have it available with two custom domains (myapp.cc and myapp.eu). When I use the default setup, the CallbackPath can only be a relative path (to the current domain)
The code documentation of CallbackPath indicates it's relative to the application's base path:
/// <summary>
/// The request path within the application's base path where the user-agent will be returned.
/// The middleware will process this request when it arrives.
/// </summary>
public PathString CallbackPath { get; set; }
I want to make sure the CallBack happens to the (only) domain that I whitelisted on the OAuth backend. I know I can implement everything manually, but I was hoping there would be an easy way to work around this design and still benefit from the baked in Authentication options.
So even if a user is logging on on the myapp.cc or the myapp.eu or the myapp.azurewebsites.net , it should redirect to myapp.azurewebsites.net/ (which is whitelisted on my Auth service)
A part of my Startup.cs file is pasted below:
services.AddAuthentication(options =>
{
options.DefaultChallengeScheme = "MyService";
})
.AddCookie()
.AddOAuth("MyService", "MyService",
options =>
{
options.ClientId = settings.ClientId;
options.ClientSecret = settings.ClientOauthSecret;
options.CallbackPath = "/relativeonlypath";
options.SaveTokens = true;
options.SignInScheme = IdentityConstants.ExternalScheme;
/// ... removed for brevity
}
);
Any idea on how to implement this?
Thank you
I'm not sure it's possible, because to verify that the user is redirected to your application as part of a "genuine" authentication flow, the ASP.NET OAuth handler performs the following steps:
Before redirecting the user to the OAuth service, ASP.NET Core generates a "correlation" cookie that is tied to the current domain; and
When the user is redirected to the app, the handler looks for this cookie and validates its content.
So if the correlation cookie is generated in step #1 for one domain, let's say myapp.cc, and the user is redirected to another domain, myapp.azurewebsites.net, ASP.NET Core might not be able to read it because the browser will not have included it in the redirection request.
Note
As seen in the first comments, the original thought was to leverage the SameSiteproperty of the correlation cookie to have it sent by the browser to the second domain.
This was all wrong, apologies!
I now think that you have 2 different options:
Redirect every request from myapp.cc and myapp.eu to myapp.azurewebsites.net, so that when the authentication flow happens, we're already on the right domain; or
Redirect the user to the myapp.azurewebsites.net domain before redirecting them to the OAuth server.
I won't go into the first solution, as there's plenty of ways to achieve this.
Here's some code that I haven't tested that could work for the second solution:
services
.AddAuthentication(options =>
{
options.DefaultChallengeScheme = "MyService";
})
.AddCookie()
.AddOAuth("MyService", options =>
{
options.Events.OnRedirectToAuthorizationEndpoint = context =>
{
var currentRequestUri = new Uri(context.Request.GetDisplayUrl());
// 1. If we're not on the correct domain, redirect the user to the same page, but on the expected domain.
// The assumption is that the authentication flow will also kick in on the other domain (see 2).
if (currentRequestUri.Host != "myapp.azurewebsites.net")
{
var applicationRedirectUri = new UriBuilder(currentRequestUri)
{
Host = "myapp.azurewebsites.net"
}.Uri.ToString();
context.Response.Redirect(applicationRedirectUri);
return Task.CompletedTask;
}
// 2. If we reach here, it means we're on the right domain, so we can redirect to the OAuth server.
context.Response.Redirect(context.RedirectUri);
return Task.CompletedTask;
};
});

Blazor server - get AAD access token from current logged in user

I'm trying to retrieve an Azure AD token from my Blazor server website, so I can add this as an Authorization header in a downstream API service.
I was able to setup AAD authentication in the website (which works perfectly fine), but I'm unable to retrieve an access token, which needs to be added as an authorization header in my downstream API calls.
I'm using Blazor server (so not WebAssembly) in .NET core 3.1
This is my current setup, but the access token is always null and can't seem to fix it.
Any help is greatly appreciated!
Startup.cs
services.AddAuthentication(AzureADDefaults.AuthenticationScheme)
.AddAzureAD(options => Configuration.Bind("AzureAd", options))
.AddCookie();
services.Configure<OpenIdConnectOptions>(AzureADDefaults.AuthenticationScheme, options =>
{
options.SaveTokens = true;
});
_Host.cshtml
I added a code block and try to retrieve the accessToken from the HttpContext.
Then I can propagate the value to my controllers using a CascadingValue object in my app.razor
However, the access_token is always empty. The "User.Identity.IsAuthenticated" is true, so it is entering my if statement.
#{
string accessToken = null;
if (User.Identity.IsAuthenticated)
{
accessToken = await HttpContext.GetTokenAsync("access_token");
// accessToken is always empty :(
}
}
What am I missing here?
Most of the articles I could find about this was for Blazor WebAssembly
Side note:
Not sure if this is relevant for this, but in my controllers, I'm able to get the ClaimsPrincipal object. But I don't think I'm able to get a bearer token from that object (but thought it was worth mentioning here).
var authState = await AuthenticationStateProvider.GetAuthenticationStateAsync();
var user = authState.User;
I found the solution to this issue.
I needed to make use of the "Microsoft.Identity.Web" & "Microsoft.Identity.Web.UI" nuget packages to be able to retrieve the tokens to call the API.
I followed the solution explained in following sample: https://github.com/Azure-Samples/active-directory-aspnetcore-webapp-openidconnect-v2/tree/b07a9e06206f7274fdcadc34a50b8bebf9666fcf/4-WebApp-your-API/4-1-MyOrg#step-2-register-the-sample-with-your-azure-active-directory-tenant
I did encounter some issues with the consent mechanism that is needed the first time a user logs in. This is solved by adding the "Microsoft Identity consent and conditional access handler service" which is explained here: https://github.com/AzureAD/microsoft-identity-web/wiki/Managing-incremental-consent-and-conditional-access
So case closed :)
Hope this helps someone in the future as well.
For completeness, I added the changes I did in my code:
Startup.cs
services.AddControllersWithViews(options =>
{
var policy = new AuthorizationPolicyBuilder()
.RequireAuthenticatedUser()
.Build();
options.Filters.Add(new AuthorizeFilter(policy));
}).AddMicrosoftIdentityUI();
services.AddAuthentication(OpenIdConnectDefaults.AuthenticationScheme)
.AddMicrosoftIdentityWebApp(Configuration)
.EnableTokenAcquisitionToCallDownstreamApi(new string[] { Configuration["DashboardAPI:ApiScope"] })
.AddInMemoryTokenCaches();
services.AddServerSideBlazor()
.AddMicrosoftIdentityConsentHandler();
API controller class
Injected "ITokenAcquisition" class in my constructor and assigned it to the "_tokenAcquisition" var
Each time I call the API, I execute the "PrepareAuthenticatedClient" method first
private async Task PrepareAuthenticatedClient()
{
var accessToken = await _tokenAcquisition.GetAccessTokenForUserAsync(new[] { _dashboardAPIScope });
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
}
In the view where I call the API controller, I added a try-catch block for the consent handling and inject the consenthandler
[Inject]
MicrosoftIdentityConsentAndConditionalAccessHandler ConsentHandler { get; set; }
try
{
// Call to my API controller
}
catch(Exception ex)
{
ConsentHandler.HandleException(ex);
}

Cookie authentication issue ASP.NET CORE 2.1. AWS load balancing

UPDATE: It looks like following code is failing:
services.AddDataProtection()
.SetApplicationName(appname)
.PersistKeysToRedis(redis, "DataProtectionKeys")
.ProtectKeysWithCertificate(LoadCert(Configuration));
It's unable to read certificate from the pfx file.
UPDATE2:
Oh my! Certificate file has been excluded by .gitignore!:)) Live&Learn. At least we lived, right!?;))
Initial question:
I have ASP.NET Core 2.1 app deployed behind AWS load balancer in Docker container.
When I'm trying to log in into app from the login page. I am getting InvalidOperationException with this justification:
No authenticationScheme was specified, and there was no
DefaultChallengeScheme found.
But when I hit same URL again, it actually moving to the proper page and works for a while, then again throws same exception with HTTP status 500
and after 2nd attempt to open same page it succeeds. Interestingly enough Chrome is not as robust as IE: if IE unable to recover after exception Chrome always return 404 on subsequent submission of the page, which produced aforementioned exception.
So I would appreciate, if somebody would be able to provide me with
ideas how to remedy the situation Obviously issue is related to the
authentication, but I could not figure out exactly what should be done.
Here is relevant exert from ConfigureServices() in Startup.cs:
string appname = "MyApp";
var redis = ConnectionMultiplexer.Connect(Configuration.GetConnectionString("RedisConnection"));
services.AddDataProtection()
.SetApplicationName(appname)
.PersistKeysToRedis(redis, "DataProtectionKeys")
.ProtectKeysWithCertificate(LoadCert(Configuration));
services.AddMvc().SetCompatibilityVersion(CompatibilityVersion.Version_2_1);
services.AddAuthentication( CookieAuthenticationDefaults.AuthenticationScheme)
.AddCookie(CookieAuthenticationDefaults.AuthenticationScheme,
options =>
{
options.LoginPath = new PathString("/Area/Ctrl/Login");
options.LogoutPath = new PathString("/Area/Ctrl/Logout");
options.Cookie.IsEssential = true;
});
services.AddDistributedRedisCache(o =>
{
o.Configuration = Configuration.GetConnectionString("RedisConnection");
});
services.AddSession(options =>
{
options.Cookie.Name = appname;
options.IdleTimeout = TimeSpan.FromSeconds(600);
});
Here is relevant code from Configure() in Startup.cs:
app.UseForwardedHeaders();
app.UseHttpsRedirection();
app.UseStaticFiles();
app.UseAuthentication();
app.UseSession();
app.UseMvc(routes =>
{
routes.MapRoute(
name: "areas",
template: "{area:exists}/{controller=Ctrl}/{action=Login}/{id?}"
);
});
Here is how I am setting principal in controller, which handles login:
ClaimsIdentity identity = new ClaimsIdentity(GetUserRoleClaims(dbUserData), CookieAuthenticationDefaults.AuthenticationScheme);
ClaimsPrincipal principal = new ClaimsPrincipal(identity);
if (principal == null)
throw new ApplicationException($"Could not create principal for {user?.UserName} user.");
await httpContext.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme, principal);
if (httpContext.User == null)
{
httpContext.User = principal;
}
Ok, everything is working now.:)
This is what made a difference:
If app is under load balancing all instances has to share Data Protection encryption keys(e.g. use same key ring). Hence comes the Redis and a cert. Session should also be shared. Hence comes the Redis again.
Certificate for ProtectKeysWithCertificate() call should load correctly. If it could not be loaded do not make that call at all, but that's would be really bad idea. Just figure out why it's not loading.
To avoid InvalidOperationException being thrown in custom authentication HttpContext. User should be assigned manually inside Login action.
One important thing about certificate: Data Protection module supports only certs with CAPI private keys. CNG ones are left behind.

Categories