Skip to content
Draft
Show file tree
Hide file tree
Changes from 19 commits
Commits
Show all changes
41 commits
Select commit Hold shift + click to select a range
efa3254
Start building Lexbox login
imnasnainaec Feb 5, 2026
f8090fb
Add Lexbox login to CreateProject
imnasnainaec Feb 10, 2026
24822a8
Add AuthStatus handling
imnasnainaec Feb 10, 2026
aa4e76e
Fix frontend lint, tests
imnasnainaec Feb 10, 2026
723b83f
Adjust API routes
imnasnainaec Feb 10, 2026
79eb3a4
Remove Azure AD artifacts
imnasnainaec Feb 18, 2026
525194b
Comment, trim, and style
imnasnainaec Feb 18, 2026
24c6bc8
Change LexboxAuthSettings
imnasnainaec Feb 19, 2026
ada7f55
Fix redirect uri
imnasnainaec Feb 23, 2026
0c4fb8a
Use client-id of lexbox pr 2180
imnasnainaec Feb 23, 2026
ba6b9c7
Refactor attempt
imnasnainaec Feb 23, 2026
82c75b0
Attempted url fix
imnasnainaec Feb 23, 2026
2d39aa0
Manually config
imnasnainaec Feb 24, 2026
f78dbb0
Fix discovery
imnasnainaec Feb 24, 2026
a55747d
Tidy
imnasnainaec Feb 24, 2026
39b518d
Fix frontend test
imnasnainaec Feb 24, 2026
6a298e3
Return redundant controller method
imnasnainaec Feb 24, 2026
37f1a1a
Add logout endpoint; Tidy React component
imnasnainaec Feb 24, 2026
663fb0f
Fix tests
imnasnainaec Feb 24, 2026
2ca4dbf
Update name and test
imnasnainaec Mar 2, 2026
7eecbec
Simplify config
imnasnainaec Mar 2, 2026
928f3e2
Merge branch 'master' into lexbox-login
imnasnainaec Mar 2, 2026
bae8499
Simplify Claims and align with LexCore/Auth/LexAuthConstants
imnasnainaec Mar 2, 2026
8956f13
Alphabetize
imnasnainaec Mar 2, 2026
fe241dd
Pick some bunny nits
imnasnainaec Mar 2, 2026
7ef359f
Merge branch 'master' into lexbox-login
imnasnainaec Mar 25, 2026
a0a137f
Handle a couple Devin comments
imnasnainaec Mar 25, 2026
d491162
Fix auth and callback in dev
imnasnainaec Mar 26, 2026
9ec6288
Merge branch 'master' into lexbox-login
imnasnainaec Apr 6, 2026
43476ed
Fix authentication handling
imnasnainaec Apr 6, 2026
f01e979
Add project-fetching
imnasnainaec Apr 6, 2026
cb5cf0c
Complete the chain
imnasnainaec Apr 7, 2026
7c04869
Switch scope from sendandreceive to lexboxapi
imnasnainaec Apr 7, 2026
b8e1837
Extract models
imnasnainaec Apr 7, 2026
902cd55
Start adding Lexbox proj selection
imnasnainaec Apr 8, 2026
c9d9183
Enable Lexbox project selection
imnasnainaec Apr 8, 2026
e41892b
Create LexboxQueryService
imnasnainaec Apr 8, 2026
19390ca
Try to fetch project entries
imnasnainaec Apr 8, 2026
117621d
Use LF api
imnasnainaec Apr 10, 2026
3166957
Extract LexboxAuthService
imnasnainaec Apr 10, 2026
d54676d
Disable backdrop click
imnasnainaec Apr 10, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@
"langtags",
"ldml",
"letsencrypt",
"Lexbox",
"Linq",
"maint",
"Memberwise",
Expand Down
114 changes: 114 additions & 0 deletions Backend.Tests/Controllers/AuthControllerTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
using System;
using System.Collections.Generic;
using System.Security.Claims;
using System.Threading.Tasks;
using Backend.Tests.Mocks;
using BackendFramework.Controllers;
using BackendFramework.Models;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using NUnit.Framework;

namespace Backend.Tests.Controllers
{
internal sealed class AuthControllerTests : IDisposable
{
private PermissionServiceMock _permissionService = null!;
private AuthController _controller = null!;

private const string UserId = "AuthControllerTestsUserId";

public void Dispose()
{
_controller?.Dispose();
GC.SuppressFinalize(this);
}

[SetUp]
public void Setup()
{
var configValues = new Dictionary<string, string?> { { "LexboxAuth:PostLoginRedirect", "/" } };
var configuration = new ConfigurationBuilder().AddInMemoryCollection(configValues).Build();
_permissionService = new PermissionServiceMock();
_controller = new AuthController(configuration, _permissionService);
}

[Test]
public async Task GetAuthStatusUnauthorizedReturnsForbid()
{
_controller.ControllerContext.HttpContext = PermissionServiceMock.UnauthorizedHttpContext();

var result = await _controller.GetAuthStatus();

Assert.That(result, Is.InstanceOf<ForbidResult>());
}

[Test]
public async Task GetAuthStatusReturnsLexboxUserWhenLoggedIn()
{
var claims = new List<Claim> { new("sub", "lex-1"), new("preferred_username", "Lex User") };
var authResult = AuthenticateResult.Success(new AuthenticationTicket(
new ClaimsPrincipal(new ClaimsIdentity(claims, "LexboxCookie")), "LexboxCookie"));
_controller.ControllerContext.HttpContext = GetAuthContext(authResult);

var result = await _controller.GetAuthStatus() as OkObjectResult;

Assert.That(result, Is.Not.Null);
var authStatus = result.Value as AuthStatus;
Assert.That(authStatus, Is.Not.Null);
Assert.That(authStatus.IsLoggedIn, Is.True);
Assert.That(authStatus.LoggedInAs, Is.EqualTo("Lex User"));
Assert.That(authStatus.UserId, Is.EqualTo("lex-1"));
}

[Test]
public async Task GetAuthStatusReturnsLoggedOutWhenNotAuthenticatedByLexboxCookie()
{
_controller.ControllerContext.HttpContext = GetAuthContext(AuthenticateResult.NoResult());

var result = await _controller.GetAuthStatus() as OkObjectResult;

Assert.That(result, Is.Not.Null);
var authStatus = result.Value as AuthStatus;
Assert.That(authStatus, Is.Not.Null);
Assert.That(authStatus.IsLoggedIn, Is.False);
Assert.That(authStatus.LoggedInAs, Is.Null);
Assert.That(authStatus.UserId, Is.Null);
}

[Test]
public async Task GetLexboxLoginReturnsExpectedLoginPath()
{
_controller.ControllerContext.HttpContext = GetAuthContext(AuthenticateResult.NoResult());

var result = await _controller.GetLexboxLogin();

Assert.That(result, Is.InstanceOf<EmptyResult>());
}

[Test]
public async Task LogOutLexboxReturnsNoContent()
{
var claims = new List<Claim> { new("sub", "lex-1"), new("preferred_username", "Lex User") };
var authResult = AuthenticateResult.Success(new AuthenticationTicket(
new ClaimsPrincipal(new ClaimsIdentity(claims, "LexboxCookie")), "LexboxCookie"));
_controller.ControllerContext.HttpContext = GetAuthContext(authResult);

var result = await _controller.LogOutLexbox();

Assert.That(result, Is.InstanceOf<NoContentResult>());
}

private static HttpContext GetAuthContext(AuthenticateResult authenticateResult)
{
var context = PermissionServiceMock.HttpContextWithUserId(UserId);
var services = new ServiceCollection();
services.AddSingleton<IAuthenticationService>(new AuthenticationServiceMock(authenticateResult));
context.RequestServices = services.BuildServiceProvider();
return context;
}
}
}
25 changes: 25 additions & 0 deletions Backend.Tests/Mocks/AuthenticationServiceMock.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
using System.Security.Claims;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Http;

namespace Backend.Tests.Mocks
{
internal sealed class AuthenticationServiceMock(AuthenticateResult authenticateResult) : IAuthenticationService
{
public Task<AuthenticateResult> AuthenticateAsync(HttpContext context, string? scheme)
=> Task.FromResult(authenticateResult);

public Task ChallengeAsync(HttpContext context, string? scheme, AuthenticationProperties? properties)
=> Task.CompletedTask;

public Task ForbidAsync(HttpContext context, string? scheme, AuthenticationProperties? properties)
=> Task.CompletedTask;

public Task SignInAsync(HttpContext context, string? scheme, ClaimsPrincipal principal,
AuthenticationProperties? properties) => Task.CompletedTask;

public Task SignOutAsync(HttpContext context, string? scheme, AuthenticationProperties? properties)
=> Task.CompletedTask;
}
}
7 changes: 4 additions & 3 deletions Backend/BackendFramework.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,10 @@
<PackageReference Include="RelaxNG" Version="3.2.3">
<NoWarn>NU1701</NoWarn>
</PackageReference>
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.17" />
<PackageReference Include="Microsoft.IdentityModel.Tokens" Version="8.12.1" />
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.12.1" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.24" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" Version="8.0.24" />
<PackageReference Include="Microsoft.IdentityModel.Tokens" Version="8.16.0" />
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.16.0" />
Comment thread
coderabbitai[bot] marked this conversation as resolved.
<PackageReference Include="MailKit" Version="4.13.0" />
<PackageReference Include="MongoDB.Driver" Version="3.4.0" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="9.0.1" />
Expand Down
127 changes: 127 additions & 0 deletions Backend/Controllers/AuthController.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
using System;
using System.Threading.Tasks;
using BackendFramework.Helper;
using BackendFramework.Interfaces;
using BackendFramework.Models;
using BackendFramework.Otel;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Configuration;

namespace BackendFramework.Controllers
{
[Produces("application/json")]
[Route("v1/auth")]
public class AuthController(IConfiguration configuration, IPermissionService permissionService) : Controller
{
private readonly IConfiguration _configuration = configuration;
private readonly IPermissionService _permissionService = permissionService;

private const string otelTagName = "otel.AuthController";
private const string LexboxCookieScheme = "LexboxCookie";
private const string LexboxOidcScheme = "LexboxOidc";
private const string PostLoginRedirectConfigKey = "LexboxAuth:PostLoginRedirect";

/// <summary> Gets authentication status for the current request. </summary>
[HttpGet("status", Name = "GetAuthStatus")]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(AuthStatus))]
[ProducesResponseType(StatusCodes.Status403Forbidden)]
public async Task<IActionResult> GetAuthStatus()
{
using var activity = OtelService.StartActivityWithTag(otelTagName, "getting auth status");

if (!_permissionService.IsCurrentUserAuthenticated(HttpContext))
{
return Forbid();
}

var result = await HttpContext.AuthenticateAsync(LexboxCookieScheme);
if (!result.Succeeded || result.Principal is null)
{
return Ok(AuthStatus.LoggedOut());
}

var user = GetUserFromClaims(result.Principal);
return user is null ? Ok(AuthStatus.LoggedOut()) : Ok(AuthStatus.LoggedIn(user));
}

/// <summary> Generates a redirect to Lexbox login for OIDC sign-in. </summary>
[HttpGet("lexbox-login", Name = "GetLexboxLogin")]
[ProducesResponseType(StatusCodes.Status302Found)]
[ProducesResponseType(StatusCodes.Status500InternalServerError)]
public async Task<IActionResult> GetLexboxLogin()
{
using var activity = OtelService.StartActivityWithTag(otelTagName, "getting lexbox login");

var redirectUrl = NormalizeReturnUrl(_configuration[PostLoginRedirectConfigKey])
?? NormalizeReturnUrl(Domain.FrontendDomain)
?? "/";
var authProperties = new AuthenticationProperties { RedirectUri = redirectUrl };

return await ChallengeLexboxAsync(authProperties);
}

/// <summary> Signs out the current user from Lexbox cookie and OIDC. </summary>
[HttpGet("lexbox-logout", Name = "LogOutLexbox")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
public async Task<IActionResult> LogOutLexbox()
{
using var activity = OtelService.StartActivityWithTag(otelTagName, "logging out");

await HttpContext.SignOutAsync(LexboxCookieScheme);
await HttpContext.SignOutAsync(LexboxOidcScheme);

return NoContent();
}

private async Task<IActionResult> ChallengeLexboxAsync(AuthenticationProperties authProperties)
{
try
{
await HttpContext.ChallengeAsync(LexboxOidcScheme, authProperties);
return new EmptyResult();
}
catch (Exception ex)
{
return Problem(title: "Lexbox OIDC challenge failed", detail: ex.Message,
statusCode: StatusCodes.Status500InternalServerError);
}
}

private static string? NormalizeReturnUrl(string? url)
{
url = url?.Trim();
if (string.IsNullOrEmpty(url) || !Uri.TryCreate(url, UriKind.RelativeOrAbsolute, out var uri))
{
return null;
}

return uri.IsAbsoluteUri ? uri.PathAndQuery : uri.ToString();
}

private static LexboxAuthUser? GetUserFromClaims(System.Security.Claims.ClaimsPrincipal principal)
{
var userId = principal.FindFirst("sub")?.Value?.Trim();
if (string.IsNullOrEmpty(userId))
{
userId = principal.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value?.Trim();
}

var displayName = principal.FindFirst("preferred_username")?.Value
?? principal.FindFirst("email")?.Value
?? principal.FindFirst("name")?.Value
?? principal.FindFirst("upn")?.Value
?? principal.Identity?.Name;
displayName = displayName?.Trim();
if (string.IsNullOrEmpty(displayName))
{
displayName = userId;
}

return string.IsNullOrEmpty(displayName) && string.IsNullOrEmpty(userId)
? null
: new LexboxAuthUser { DisplayName = displayName, UserId = userId };
}
}
}
21 changes: 21 additions & 0 deletions Backend/Models/AuthStatus.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
namespace BackendFramework.Models
{
public class AuthStatus
{
public bool IsLoggedIn { get; set; }
public string? LoggedInAs { get; set; }
public string? UserId { get; set; }

public static AuthStatus LoggedOut() => new()
{
IsLoggedIn = false
};

public static AuthStatus LoggedIn(LexboxAuthUser user) => new()
{
IsLoggedIn = true,
LoggedInAs = user.DisplayName,
UserId = user.UserId
};
}
}
8 changes: 8 additions & 0 deletions Backend/Models/LexboxAuthUser.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
namespace BackendFramework.Models
{
public class LexboxAuthUser
{
public string? UserId { get; init; }
public string? DisplayName { get; init; }
}
}
59 changes: 57 additions & 2 deletions Backend/Startup.cs
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,21 @@ public void ConfigureServices(IServiceCollection services)
}

var key = ASCII.GetBytes(secretKey);

var lexboxAuthority = Configuration["LexboxAuth:Authority"]?.Trim().TrimEnd('/');
lexboxAuthority = string.IsNullOrEmpty(lexboxAuthority) ? "https://lexbox.org" : lexboxAuthority;
// Authorization endpoint needs to be defined before discovery happens with the metadata address.
var lexboxAuthorizationEndpoint = Configuration["LexboxAuth:AuthorizationEndpoint"]?.Trim();
lexboxAuthorizationEndpoint = string.IsNullOrEmpty(lexboxAuthorizationEndpoint)
? "https://lexbox.org/api/oauth/open-id-auth"
: lexboxAuthorizationEndpoint;
var lexboxCallbackPath = Configuration["LexboxAuth:CallbackPath"] ?? "/v1/auth/oauth-callback";
var lexboxClientId = Configuration["LexboxAuth:ClientId"] ?? "the-combine";
var lexboxMetadataAddress = Configuration["LexboxAuth:OpenIdConfigUrl"]
?? "https://lexbox.org/.well-known/openid-configuration";
var lexboxPrompt = Configuration["LexboxAuth:Prompt"] ?? "select_account";
var lexboxScope = Configuration["LexboxAuth:Scope"] ?? "profile openid offline_access sendandreceive";

services.AddAuthentication(x =>
{
x.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
Expand All @@ -144,10 +159,50 @@ public void ConfigureServices(IServiceCollection services)
x.SaveToken = true;
x.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuerSigningKey = true,
IssuerSigningKey = new SymmetricSecurityKey(key),
ValidateAudience = false,
ValidateIssuer = false,
ValidateAudience = false
ValidateIssuerSigningKey = true
};
})
.AddCookie("LexboxCookie", options =>
{
options.Cookie.HttpOnly = true;
options.Cookie.IsEssential = true;
options.Cookie.Name = "lexbox_auth";
options.Cookie.SameSite = Microsoft.AspNetCore.Http.SameSiteMode.Lax;
options.Cookie.SecurePolicy = Microsoft.AspNetCore.Http.CookieSecurePolicy.SameAsRequest;
options.ExpireTimeSpan = TimeSpan.FromHours(1);
options.SlidingExpiration = true;
})
.AddOpenIdConnect("LexboxOidc", options =>
{
options.Authority = lexboxAuthority;
options.CallbackPath = lexboxCallbackPath;
options.ClientId = lexboxClientId;
options.GetClaimsFromUserInfoEndpoint = true;
options.MetadataAddress = lexboxMetadataAddress;
options.RequireHttpsMetadata = true;
options.ResponseType = "code";
options.SaveTokens = true;
options.SignInScheme = "LexboxCookie";
options.UsePkce = true;

options.Scope.Clear();
foreach (var scope in lexboxScope.Split(' ', StringSplitOptions.RemoveEmptyEntries))
{
options.Scope.Add(scope);
}

options.Events.OnRedirectToIdentityProvider = context =>
{
if (string.IsNullOrWhiteSpace(context.ProtocolMessage.IssuerAddress))
{
context.ProtocolMessage.IssuerAddress = lexboxAuthorizationEndpoint;
}

context.ProtocolMessage.Prompt = lexboxPrompt;
return System.Threading.Tasks.Task.CompletedTask;
};
});

Expand Down
Loading
Loading