We are looking to implement MS-OFBA in the ASP.NET Core WebDAV Server Sample (https://www.webdavsystem.com/server/server_examples/cross_platform_asp_net_core_sql/). The sample already has code for basic and digest authentication but we need to support MS-OFBA.
I've implemented an MSOFBAuthMiddleware class similar to the existing basic and digest middleware classes where we set the required "X-FORMS_BASED_AUTH_" headers if it's a request from an Office application.
This works up to a point:
- The headers are sent back and Word (Excel, etc.) opens the dialogue box and shows the login page.
- We can login successfully, set an authentication cookie, and the page redirects the user.
- On this redirect though when we check if the user is authenticated the httpContext.User.Identity.IsAuthenticated value is always false.
Initially we've been trying this with a local login page but ultimately we'd prefer to use our existing Identity Server login page. Again we can get the login page to show but the redirect isn't working.
In Identity Server after logging in we should redirect to "/connect/authorize/login?client_id=mvc.manual&response_type=id_token&scope=openid%20profile%20&redirect_uri=http%3A%2F%2Flocalhost%3A5000%2Faccount%2Fcallback&state=random_state&nonce=random_nonce&response_mode=form_post" but in actuality we are redirected to the root of the application "/".
Update: I've resolved this redirection problem and Identity Server now redirects to the correct URL, but the httpContext.User.Identity.IsAuthenticated value is still always false in the middleware.
Startup.cs (partial)
public void ConfigureServices(IServiceCollection services)
{
services.AddWebDav(Configuration, HostingEnvironment);
services.AddSingleton<WebSocketsService>();
services.AddMvc();
services.Configure<DavUsersOptions>(options => Configuration.GetSection("DavUsers").Bind(options));
services.AddAuthentication(o =>
{
o.DefaultChallengeScheme = CookieAuthenticationDefaults.AuthenticationScheme;
o.DefaultAuthenticateScheme = CookieAuthenticationDefaults.AuthenticationScheme;
o.DefaultSignInScheme = CookieAuthenticationDefaults.AuthenticationScheme;
}
).AddCookie(CookieAuthenticationDefaults.AuthenticationScheme);
}
public void Configure(IApplicationBuilder app, IHostingEnvironment env, ILoggerFactory loggerFactory)
{
loggerFactory.AddConsole(Configuration.GetSection("Logging"));
loggerFactory.AddDebug();
if (env.IsDevelopment())
app.UseDeveloperExceptionPage();
//app.UseBasicAuth();
//app.UseDigestAuth();
app.UseMSOFBAuth();
app.UseAuthentication();
app.UseWebSockets();
app.UseWebSocketsMiddleware();
app.UseMvc();
app.UseWebDav(HostingEnvironment);
}
MSOFBAuthMiddleware.cs (partial)
public async Task Invoke(HttpContext context)
{
// If Authorize header is present - perform request authenticating.
if (IsAuthorizationPresent(context.Request))
{
ClaimsPrincipal userPrincipal = AuthenticateRequest(context.Request);
if (userPrincipal != null)
{
// Authenticated succesfully.
context.User = userPrincipal;
await next(context);
}
else
{
// Invalid credentials.
Unauthorized(context);
return;
}
}
else
{
if (IsOFBAAccepted(context.Request))
{
// The Unauthorized method subsequently call the SetAuthenticationHeader() method below.
Unauthorized(context);
return;
}
else
{
await next(context);
}
}
}
/// <summary>
/// Analyzes request headers to determine MS-OFBA support.
/// </summary>
/// <remarks>
/// MS-OFBA is supported by Microsoft Office 2007 SP1 and later versions
/// and any application that provides X-FORMS_BASED_AUTH_ACCEPTED: t header
/// in OPTIONS request.
/// </remarks>
private bool IsOFBAAccepted(HttpRequest request)
{
// In case application provided X-FORMS_BASED_AUTH_ACCEPTED header
string ofbaAccepted = request.Headers["X-FORMS_BASED_AUTH_ACCEPTED"];
if ((ofbaAccepted != null) && ofbaAccepted.Equals("T", StringComparison.CurrentCultureIgnoreCase))
{
return true;
}
// Microsoft Office does not submit X-FORMS_BASED_AUTH_ACCEPTED header, but it still supports MS-OFBA,
// Microsoft Office includes "Microsoft Office" string into User-Agent header
string userAgent = request.Headers["User-Agent"];
if ((userAgent != null) && userAgent.Contains("Microsoft Office"))
{
return true;
}
return false;
}
/// <summary>
/// Sets authentication header to request basic authentication and show login dialog.
/// </summary>
/// <param name="context">Instance of current context.</param>
/// <returns>Successfull task result.</returns>
protected override async Task SetAuthenticationHeader(object context)
{
HttpContext httpContext = (HttpContext)context;
if (httpContext.User == null || !httpContext.User.Identity.IsAuthenticated)
{
string redirectLocation = httpContext.Response.Headers["Location"];
string successUri = "http://localhost:5000/account/success";
var client = new DiscoveryClient("http://accounts:43000");
client.Policy.RequireHttps = false;
var disco = await client.GetAsync();
var loginUri = new AuthorizeRequest(disco.AuthorizeEndpoint).CreateAuthorizeUrl(
clientId: "mvc.manual",
responseType: "id_token",
scope: "openid profile ",
redirectUri: "http://localhost:5000/account/callback",
state: "random_state",
nonce: "random_nonce",
responseMode: "form_post");
httpContext.Response.StatusCode = 403;
httpContext.Response.Headers.Add("X-FORMS_BASED_AUTH_REQUIRED", new[] { loginUri });
httpContext.Response.Headers.Add("X-FORMS_BASED_AUTH_RETURN_URL", new[] { successUri });
httpContext.Response.Headers.Add("X-FORMS_BASED_AUTH_DIALOG_SIZE", new[] { string.Format("{0}x{1}", 800, 640) });
}
}
AccountController.cs (partial)
public async Task<IActionResult> Callback()
{
var state = Request.Form["state"].FirstOrDefault();
var idToken = Request.Form["id_token"].FirstOrDefault();
var error = Request.Form["error"].FirstOrDefault();
if (!string.IsNullOrEmpty(error)) throw new Exception(error);
if (!string.Equals(state, "random_state")) throw new Exception("invalid state");
var user = await ValidateIdentityToken(idToken);
await HttpContext.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme, user);
return Redirect("http://localhost:5000/account/success");
}
private async Task<ClaimsPrincipal> ValidateIdentityToken(string idToken)
{
var user = await ValidateJwt(idToken);
var nonce = user.FindFirst("nonce")?.Value ?? "";
if (!string.Equals(nonce, "random_nonce")) throw new Exception("invalid nonce");
return user;
}
private static async Task<ClaimsPrincipal> ValidateJwt(string jwt)
{
// read discovery document to find issuer and key material
var client = new DiscoveryClient("http://accounts:43000");
client.Policy.RequireHttps = false;
var disco = await client.GetAsync();
var keys = new List<SecurityKey>();
foreach (var webKey in disco.KeySet.Keys)
{
var e = Base64Url.Decode(webKey.E);
var n = Base64Url.Decode(webKey.N);
var key = new RsaSecurityKey(new RSAParameters { Exponent = e, Modulus = n })
{
KeyId = webKey.Kid
};
keys.Add(key);
}
var parameters = new TokenValidationParameters
{
ValidIssuer = disco.Issuer,
ValidAudience = "mvc.manual",
IssuerSigningKeys = keys,
NameClaimType = JwtClaimTypes.Name,
RoleClaimType = JwtClaimTypes.Role
};
var handler = new JwtSecurityTokenHandler();
handler.InboundClaimTypeMap.Clear();
var user = handler.ValidateToken(jwt, parameters, out var _);
return user;
}
Clients.cs (partial) - from Identity Server project
public static Client WebDavServiceManual { get; } = new Client
{
ClientId = "mvc.manual",
ClientName = "MVC Manual",
ClientUri = "http://localhost:5000",
AllowedGrantTypes = GrantTypes.Implicit,
RedirectUris = { "http://localhost:5000/account/callback", "http://localhost:5000/account/success" },
PostLogoutRedirectUris = { "http://localhost:5000/" },
AllowedScopes = new List<string>
{
IdentityServerConstants.StandardScopes.OpenId,
IdentityServerConstants.StandardScopes.Profile,
IdentityServerConstants.StandardScopes.Email,
IdentityServerConstants.StandardScopes.OfflineAccess
}
};
Thanks, Stuart.