From efa3254f44d68e61d705aef5a3fee170747c7fc1 Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Thu, 5 Feb 2026 17:05:38 -0500 Subject: [PATCH 01/38] Start building Lexbox login --- Backend/Controllers/AuthController.cs | 63 +++++ Backend/Interfaces/ILexboxAuthService.cs | 10 + Backend/Models/AuthStatus.cs | 27 ++ Backend/Models/LexboxLoginUrl.cs | 7 + Backend/Services/LexboxAuthService.cs | 137 ++++++++++ Backend/Startup.cs | 3 + Backend/appsettings.json | 9 + src/api/.openapi-generator/FILES | 3 + src/api/api.ts | 1 + src/api/api/auth-api.ts | 238 ++++++++++++++++++ src/api/models/auth-status.ts | 39 +++ src/api/models/index.ts | 2 + src/api/models/lexbox-login-url.ts | 27 ++ src/backend/index.ts | 17 ++ src/components/Lexbox/LexboxLogin.tsx | 111 ++++++++ .../Lexbox/tests/LexboxLogin.test.tsx | 68 +++++ 16 files changed, 762 insertions(+) create mode 100644 Backend/Controllers/AuthController.cs create mode 100644 Backend/Interfaces/ILexboxAuthService.cs create mode 100644 Backend/Models/AuthStatus.cs create mode 100644 Backend/Models/LexboxLoginUrl.cs create mode 100644 Backend/Services/LexboxAuthService.cs create mode 100644 src/api/api/auth-api.ts create mode 100644 src/api/models/auth-status.ts create mode 100644 src/api/models/lexbox-login-url.ts create mode 100644 src/components/Lexbox/LexboxLogin.tsx create mode 100644 src/components/Lexbox/tests/LexboxLogin.test.tsx diff --git a/Backend/Controllers/AuthController.cs b/Backend/Controllers/AuthController.cs new file mode 100644 index 0000000000..aa05cb064a --- /dev/null +++ b/Backend/Controllers/AuthController.cs @@ -0,0 +1,63 @@ +using System; +using System.Threading.Tasks; +using BackendFramework.Interfaces; +using BackendFramework.Models; +using BackendFramework.Otel; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; + +namespace BackendFramework.Controllers +{ + [Produces("application/json")] + [Route("v1/auth")] + public class AuthController( + IUserRepository userRepo, + IPermissionService permissionService, + ILexboxAuthService lexboxAuthService) : Controller + { + private readonly IUserRepository _userRepo = userRepo; + private readonly IPermissionService _permissionService = permissionService; + private readonly ILexboxAuthService _lexboxAuthService = lexboxAuthService; + + private const string otelTagName = "otel.AuthController"; + + /// Gets authentication status for the current request. + [AllowAnonymous] + [HttpGet("status", Name = "GetAuthStatus")] + [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(AuthStatus))] + public async Task GetAuthStatus() + { + using var activity = OtelService.StartActivityWithTag(otelTagName, "getting auth status"); + + if (!_permissionService.IsCurrentUserAuthenticated(HttpContext)) + { + return Ok(AuthStatus.LoggedOut()); + } + + var userId = _permissionService.GetUserId(HttpContext); + var user = await _userRepo.GetUser(userId); + return user is null ? Ok(AuthStatus.LoggedOut()) : Ok(AuthStatus.LoggedInUser(user)); + } + + /// Generates a Lexbox login URL for OIDC sign-in. + [AllowAnonymous] + [HttpGet("lexbox/login-url", Name = "GetLexboxLoginUrl")] + [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(LexboxLoginUrl))] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] + public IActionResult GetLexboxLoginUrl() + { + using var activity = OtelService.StartActivityWithTag(otelTagName, "getting lexbox login url"); + + try + { + var result = _lexboxAuthService.CreateLoginUrl(Request); + return Ok(result); + } + catch (InvalidOperationException ex) + { + return Problem(ex.Message, statusCode: StatusCodes.Status500InternalServerError); + } + } + } +} diff --git a/Backend/Interfaces/ILexboxAuthService.cs b/Backend/Interfaces/ILexboxAuthService.cs new file mode 100644 index 0000000000..65c9e5484a --- /dev/null +++ b/Backend/Interfaces/ILexboxAuthService.cs @@ -0,0 +1,10 @@ +using BackendFramework.Models; +using Microsoft.AspNetCore.Http; + +namespace BackendFramework.Interfaces +{ + public interface ILexboxAuthService + { + LexboxLoginUrl CreateLoginUrl(HttpRequest request); + } +} diff --git a/Backend/Models/AuthStatus.cs b/Backend/Models/AuthStatus.cs new file mode 100644 index 0000000000..256c111e65 --- /dev/null +++ b/Backend/Models/AuthStatus.cs @@ -0,0 +1,27 @@ +namespace BackendFramework.Models +{ + public class AuthStatus + { + public bool LoggedIn { get; set; } + public string? LoggedInAs { get; set; } + public string? UserId { get; set; } + + public static AuthStatus LoggedOut() => new() + { + LoggedIn = false, + LoggedInAs = null, + UserId = null, + }; + + public static AuthStatus LoggedInUser(User user) + { + var displayName = string.IsNullOrWhiteSpace(user.Name) ? user.Username : user.Name; + return new AuthStatus + { + LoggedIn = true, + LoggedInAs = displayName, + UserId = user.Id, + }; + } + } +} diff --git a/Backend/Models/LexboxLoginUrl.cs b/Backend/Models/LexboxLoginUrl.cs new file mode 100644 index 0000000000..85b9fa31c1 --- /dev/null +++ b/Backend/Models/LexboxLoginUrl.cs @@ -0,0 +1,7 @@ +namespace BackendFramework.Models +{ + public class LexboxLoginUrl + { + public string Url { get; set; } = ""; + } +} diff --git a/Backend/Services/LexboxAuthService.cs b/Backend/Services/LexboxAuthService.cs new file mode 100644 index 0000000000..22314ebdb1 --- /dev/null +++ b/Backend/Services/LexboxAuthService.cs @@ -0,0 +1,137 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Security.Cryptography; +using BackendFramework.Interfaces; +using BackendFramework.Models; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.WebUtilities; +using Microsoft.Extensions.Configuration; + +namespace BackendFramework.Services +{ + public class LexboxAuthService(IConfiguration configuration) : ILexboxAuthService + { + private readonly IConfiguration _configuration = configuration; + private readonly ConcurrentDictionary _stateStore = new(); + + private static readonly TimeSpan StateTtl = TimeSpan.FromMinutes(15); + + public LexboxLoginUrl CreateLoginUrl(HttpRequest request) + { + var settings = GetSettings(); + var state = CreateState(); + var codeVerifier = CreateCodeVerifier(); + var codeChallenge = CreateCodeChallenge(codeVerifier); + + CleanupExpiredStates(); + _stateStore[state] = new LexboxAuthState(codeVerifier, DateTimeOffset.UtcNow); + + var redirectUri = BuildRedirectUri(request); + var query = new Dictionary + { + ["scope"] = settings.Scope, + ["response_type"] = "code", + ["client_id"] = settings.ClientId, + ["redirect_uri"] = redirectUri, + ["client-request-id"] = Guid.NewGuid().ToString(), + ["x-client-SKU"] = settings.ClientSku, + ["x-client-Ver"] = settings.ClientVersion, + ["x-client-OS"] = settings.ClientOs, + ["prompt"] = settings.Prompt, + ["code_challenge"] = codeChallenge, + ["code_challenge_method"] = "S256", + ["state"] = state, + ["client_info"] = "1", + ["haschrome"] = "1", + }; + + var returnUrl = QueryHelpers.AddQueryString("/api/oauth/open-id-auth", query); + var loginUrl = $"{settings.BaseUrl.TrimEnd('/')}/login?ReturnUrl={Uri.EscapeDataString(returnUrl)}"; + + return new LexboxLoginUrl { Url = loginUrl }; + } + + private static string BuildRedirectUri(HttpRequest request) + { + var pathBase = request.PathBase.HasValue ? request.PathBase.Value : string.Empty; + return $"{request.Scheme}://{request.Host}{pathBase}/api/auth/oauth-callback"; + } + + private LexboxAuthSettings GetSettings() + { + var baseUrl = _configuration["LexboxAuth:BaseUrl"] ?? "https://lexbox.org"; + var clientId = _configuration["LexboxAuth:ClientId"] ?? string.Empty; + if (string.IsNullOrWhiteSpace(clientId)) + { + throw new InvalidOperationException("LexboxAuth:ClientId must be configured."); + } + + return new LexboxAuthSettings + { + BaseUrl = baseUrl, + ClientId = clientId, + Scope = _configuration["LexboxAuth:Scope"] + ?? "profile openid offline_access sendandreceive", + Prompt = _configuration["LexboxAuth:Prompt"] ?? "select_account", + ClientSku = _configuration["LexboxAuth:ClientSku"] ?? "TheCombine", + ClientVersion = _configuration["LexboxAuth:ClientVersion"] ?? "1.0", + ClientOs = _configuration["LexboxAuth:ClientOs"] ?? Environment.OSVersion.ToString(), + }; + } + + private static string CreateCodeVerifier() + { + Span bytes = stackalloc byte[32]; + RandomNumberGenerator.Fill(bytes); + return Base64UrlEncode(bytes); + } + + private static string CreateCodeChallenge(string verifier) + { + var bytes = System.Text.Encoding.ASCII.GetBytes(verifier); + var hash = SHA256.HashData(bytes); + return Base64UrlEncode(hash); + } + + private static string CreateState() + { + Span bytes = stackalloc byte[16]; + RandomNumberGenerator.Fill(bytes); + return $"{Guid.NewGuid()}-{Base64UrlEncode(bytes)}"; + } + + private void CleanupExpiredStates() + { + var cutoff = DateTimeOffset.UtcNow - StateTtl; + foreach (var entry in _stateStore) + { + if (entry.Value.CreatedAt < cutoff) + { + _stateStore.TryRemove(entry.Key, out _); + } + } + } + + private static string Base64UrlEncode(ReadOnlySpan data) + { + var base64 = Convert.ToBase64String(data); + return base64.TrimEnd('=') + .Replace('+', '-') + .Replace('/', '_'); + } + + private sealed record LexboxAuthState(string CodeVerifier, DateTimeOffset CreatedAt); + + private sealed class LexboxAuthSettings + { + public string BaseUrl { get; set; } = ""; + public string ClientId { get; set; } = ""; + public string Scope { get; set; } = ""; + public string Prompt { get; set; } = ""; + public string ClientSku { get; set; } = ""; + public string ClientVersion { get; set; } = ""; + public string ClientOs { get; set; } = ""; + } + } +} diff --git a/Backend/Startup.cs b/Backend/Startup.cs index 068409c652..ed12b58ba2 100644 --- a/Backend/Startup.cs +++ b/Backend/Startup.cs @@ -255,6 +255,9 @@ public void ConfigureServices(IServiceCollection services) services.AddTransient(); services.AddTransient(); + // Lexbox Auth + services.AddSingleton(); + // Lift Service - Singleton to avoid initializing the Sldr multiple times, // also to avoid leaking LanguageTag data services.AddSingleton(); diff --git a/Backend/appsettings.json b/Backend/appsettings.json index f03b8c6e7f..11e8dff19b 100644 --- a/Backend/appsettings.json +++ b/Backend/appsettings.json @@ -9,5 +9,14 @@ "Default": "Information" } }, + "LexboxAuth": { + "BaseUrl": "https://lexbox.org", + "ClientId": "", + "Scope": "profile openid offline_access sendandreceive", + "Prompt": "select_account", + "ClientSku": "TheCombine", + "ClientVersion": "1.0", + "ClientOs": "Microsoft Windows" + }, "AllowedHosts": "*" } diff --git a/src/api/.openapi-generator/FILES b/src/api/.openapi-generator/FILES index d838240e16..a2d47d65e4 100644 --- a/src/api/.openapi-generator/FILES +++ b/src/api/.openapi-generator/FILES @@ -3,6 +3,7 @@ .openapi-generator-ignore api.ts api/audio-api.ts +api/auth-api.ts api/avatar-api.ts api/banner-api.ts api/email-verify-api.ts @@ -23,6 +24,7 @@ common.ts configuration.ts git_push.sh index.ts +models/auth-status.ts models/banner-type.ts models/chart-root-data.ts models/consent-type.ts @@ -38,6 +40,7 @@ models/gloss.ts models/gram-cat-group.ts models/grammatical-info.ts models/index.ts +models/lexbox-login-url.ts models/merge-source-word.ts models/merge-undo-ids.ts models/merge-words.ts diff --git a/src/api/api.ts b/src/api/api.ts index d48242a9ed..6c1e467881 100644 --- a/src/api/api.ts +++ b/src/api/api.ts @@ -13,6 +13,7 @@ */ export * from "./api/audio-api"; +export * from "./api/auth-api"; export * from "./api/avatar-api"; export * from "./api/banner-api"; export * from "./api/email-verify-api"; diff --git a/src/api/api/auth-api.ts b/src/api/api/auth-api.ts new file mode 100644 index 0000000000..532181ad7c --- /dev/null +++ b/src/api/api/auth-api.ts @@ -0,0 +1,238 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * BackendFramework + * No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) + * + * The version of the OpenAPI document: 1.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + +import globalAxios, { AxiosPromise, AxiosInstance } from "axios"; +import { Configuration } from "../configuration"; +// Some imports not used depending on template conditions +// @ts-ignore +import { + DUMMY_BASE_URL, + assertParamExists, + setApiKeyToObject, + setBasicAuthToObject, + setBearerAuthToObject, + setOAuthToObject, + setSearchParams, + serializeDataIfNeeded, + toPathString, + createRequestFunction, +} from "../common"; +// @ts-ignore +import { + BASE_PATH, + COLLECTION_FORMATS, + RequestArgs, + BaseAPI, + RequiredError, +} from "../base"; +// @ts-ignore +import { AuthStatus } from "../models"; +// @ts-ignore +import { LexboxLoginUrl } from "../models"; +/** + * AuthApi - axios parameter creator + * @export + */ +export const AuthApiAxiosParamCreator = function ( + configuration?: Configuration +) { + return { + /** + * + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + getAuthStatus: async (options: any = {}): Promise => { + const localVarPath = `/v1/auth/status`; + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { + method: "GET", + ...baseOptions, + ...options, + }; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + setSearchParams(localVarUrlObj, localVarQueryParameter, options.query); + let headersFromBaseOptions = + baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = { + ...localVarHeaderParameter, + ...headersFromBaseOptions, + ...options.headers, + }; + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, + /** + * + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + getLexboxLoginUrl: async (options: any = {}): Promise => { + const localVarPath = `/v1/auth/lexbox/login-url`; + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { + method: "GET", + ...baseOptions, + ...options, + }; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + setSearchParams(localVarUrlObj, localVarQueryParameter, options.query); + let headersFromBaseOptions = + baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = { + ...localVarHeaderParameter, + ...headersFromBaseOptions, + ...options.headers, + }; + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, + }; +}; + +/** + * AuthApi - functional programming interface + * @export + */ +export const AuthApiFp = function (configuration?: Configuration) { + const localVarAxiosParamCreator = AuthApiAxiosParamCreator(configuration); + return { + /** + * + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async getAuthStatus( + options?: any + ): Promise< + (axios?: AxiosInstance, basePath?: string) => AxiosPromise + > { + const localVarAxiosArgs = + await localVarAxiosParamCreator.getAuthStatus(options); + return createRequestFunction( + localVarAxiosArgs, + globalAxios, + BASE_PATH, + configuration + ); + }, + /** + * + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async getLexboxLoginUrl( + options?: any + ): Promise< + (axios?: AxiosInstance, basePath?: string) => AxiosPromise + > { + const localVarAxiosArgs = + await localVarAxiosParamCreator.getLexboxLoginUrl(options); + return createRequestFunction( + localVarAxiosArgs, + globalAxios, + BASE_PATH, + configuration + ); + }, + }; +}; + +/** + * AuthApi - factory interface + * @export + */ +export const AuthApiFactory = function ( + configuration?: Configuration, + basePath?: string, + axios?: AxiosInstance +) { + const localVarFp = AuthApiFp(configuration); + return { + /** + * + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + getAuthStatus(options?: any): AxiosPromise { + return localVarFp + .getAuthStatus(options) + .then((request) => request(axios, basePath)); + }, + /** + * + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + getLexboxLoginUrl(options?: any): AxiosPromise { + return localVarFp + .getLexboxLoginUrl(options) + .then((request) => request(axios, basePath)); + }, + }; +}; + +/** + * AuthApi - object-oriented interface + * @export + * @class AuthApi + * @extends {BaseAPI} + */ +export class AuthApi extends BaseAPI { + /** + * + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof AuthApi + */ + public getAuthStatus(options?: any) { + return AuthApiFp(this.configuration) + .getAuthStatus(options) + .then((request) => request(this.axios, this.basePath)); + } + + /** + * + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof AuthApi + */ + public getLexboxLoginUrl(options?: any) { + return AuthApiFp(this.configuration) + .getLexboxLoginUrl(options) + .then((request) => request(this.axios, this.basePath)); + } +} diff --git a/src/api/models/auth-status.ts b/src/api/models/auth-status.ts new file mode 100644 index 0000000000..32741353ec --- /dev/null +++ b/src/api/models/auth-status.ts @@ -0,0 +1,39 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * BackendFramework + * No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) + * + * The version of the OpenAPI document: 1.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + +/** + * + * @export + * @interface AuthStatus + */ +export interface AuthStatus { + /** + * + * @type {boolean} + * @memberof AuthStatus + */ + loggedIn?: boolean; + /** + * + * @type {string} + * @memberof AuthStatus + */ + loggedInAs?: string | null; + /** + * + * @type {string} + * @memberof AuthStatus + */ + userId?: string | null; +} diff --git a/src/api/models/index.ts b/src/api/models/index.ts index 343ad523ca..8bd391721d 100644 --- a/src/api/models/index.ts +++ b/src/api/models/index.ts @@ -1,3 +1,4 @@ +export * from "./auth-status"; export * from "./banner-type"; export * from "./chart-root-data"; export * from "./consent-type"; @@ -12,6 +13,7 @@ export * from "./flag"; export * from "./gloss"; export * from "./gram-cat-group"; export * from "./grammatical-info"; +export * from "./lexbox-login-url"; export * from "./merge-source-word"; export * from "./merge-undo-ids"; export * from "./merge-words"; diff --git a/src/api/models/lexbox-login-url.ts b/src/api/models/lexbox-login-url.ts new file mode 100644 index 0000000000..c093bdcd0a --- /dev/null +++ b/src/api/models/lexbox-login-url.ts @@ -0,0 +1,27 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * BackendFramework + * No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) + * + * The version of the OpenAPI document: 1.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + +/** + * + * @export + * @interface LexboxLoginUrl + */ +export interface LexboxLoginUrl { + /** + * + * @type {string} + * @memberof LexboxLoginUrl + */ + url?: string | null; +} diff --git a/src/backend/index.ts b/src/backend/index.ts index a8cdef7214..97cdc06eb3 100644 --- a/src/backend/index.ts +++ b/src/backend/index.ts @@ -6,6 +6,7 @@ import { enqueueSnackbar } from "notistack"; import * as Api from "api"; import { BASE_PATH } from "api/base"; import { + AuthStatus, BannerType, ChartRootData, EmailInviteStatus, @@ -114,6 +115,7 @@ axiosInstance.interceptors.response.use(undefined, (err: AxiosError) => { // Configured OpenAPI interfaces. const audioApi = new Api.AudioApi(config, BASE_PATH, axiosInstance); +const authApi = new Api.AuthApi(config, BASE_PATH, axiosInstance); const avatarApi = new Api.AvatarApi(config, BASE_PATH, axiosInstance); const bannerApi = new Api.BannerApi(config, BASE_PATH, axiosInstance); const emailVerifyApi = new Api.EmailVerifyApi(config, BASE_PATH, axiosInstance); @@ -179,6 +181,21 @@ export function getAudioUrl(wordId: string, fileName: string): string { return `${apiBaseURL}/projects/${LocalStorage.getProjectId()}/words/${wordId}/audio/download/${fileName}`; } +/* AuthController.cs */ + +export async function getAuthStatus(): Promise { + return (await authApi.getAuthStatus(defaultOptions())).data; +} + +export async function getExternalLoginUrl(): Promise { + const response = await authApi.getLexboxLoginUrl(defaultOptions()); + return response.data.url ?? ""; +} + +export function logoutCurrentUser(): void { + LocalStorage.clearLocalStorage(); +} + /* AvatarController.cs */ /** Uploads avatar for current user. */ diff --git a/src/components/Lexbox/LexboxLogin.tsx b/src/components/Lexbox/LexboxLogin.tsx new file mode 100644 index 0000000000..c960c6ac6e --- /dev/null +++ b/src/components/Lexbox/LexboxLogin.tsx @@ -0,0 +1,111 @@ +import AccountCircleIcon from "@mui/icons-material/AccountCircle"; +import LogoutIcon from "@mui/icons-material/Logout"; +import { + Button, + ListItemIcon, + ListItemText, + Menu, + MenuItem, +} from "@mui/material"; +import { ReactElement, useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; + +import { type AuthStatus } from "api/models"; +import { getAuthStatus, getExternalLoginUrl, logoutCurrentUser } from "backend"; +import LoadingButton from "components/Buttons/LoadingButton"; + +interface LexboxLoginProps { + text?: string; + onStatusChange?: (status: "logged-in" | "logged-out") => void; +} + +export default function LexboxLogin(props: LexboxLoginProps): ReactElement { + const { t } = useTranslation(); + const [status, setStatus] = useState(undefined); + const [statusLoading, setStatusLoading] = useState(true); + const [actionLoading, setActionLoading] = useState(false); + const [menuAnchor, setMenuAnchor] = useState(null); + + const loadStatus = async (): Promise => { + setStatusLoading(true); + try { + setStatus(await getAuthStatus()); + } catch (err) { + console.error("Failed to load auth status", err); + setStatus(undefined); + } finally { + setStatusLoading(false); + } + }; + + useEffect(() => { + loadStatus(); + }, []); + + const handleLogin = async (): Promise => { + setActionLoading(true); + try { + const url = await getExternalLoginUrl(); + if (url) { + window.open(url); + } else { + console.error("Lexbox login URL is empty"); + } + } catch (err) { + console.error("Failed to get Lexbox login URL", err); + } finally { + setActionLoading(false); + } + }; + + const handleLogout = async (): Promise => { + setActionLoading(true); + try { + logoutCurrentUser(); + await loadStatus(); + props.onStatusChange?.("logged-out"); + } finally { + setActionLoading(false); + setMenuAnchor(null); + } + }; + + const isLoggedIn = status?.loggedIn ?? false; + const menuOpen = Boolean(menuAnchor); + const label = status?.loggedInAs ?? t("login.login"); + + if (!isLoggedIn) { + return ( + + {props.text ?? t("login.login")} + + ); + } + + return ( + <> + + setMenuAnchor(null)} + > + + + + + {t("userMenu.logout")} + + + + ); +} diff --git a/src/components/Lexbox/tests/LexboxLogin.test.tsx b/src/components/Lexbox/tests/LexboxLogin.test.tsx new file mode 100644 index 0000000000..1758e2b761 --- /dev/null +++ b/src/components/Lexbox/tests/LexboxLogin.test.tsx @@ -0,0 +1,68 @@ +import "@testing-library/jest-dom"; +import { act, render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; + +import LexboxLogin from "components/Lexbox/LexboxLogin"; + +jest.mock("backend", () => ({ + getAuthStatus: () => mockGetAuthStatus(), + getExternalLoginUrl: () => mockGetExternalLoginUrl(), + logoutCurrentUser: () => mockLogoutCurrentUser(), +})); + +const mockGetAuthStatus = jest.fn(); +const mockGetExternalLoginUrl = jest.fn(); +const mockLogoutCurrentUser = jest.fn(); + +const testUrl = "not-a-valid-url"; + +describe("LexboxLogin", () => { + beforeEach(() => { + jest.clearAllMocks(); + jest.spyOn(window, "open").mockImplementation(() => null); + mockGetExternalLoginUrl.mockResolvedValue(testUrl); + }); + + it("redirects to Lexbox login when logged out", async () => { + mockGetAuthStatus.mockResolvedValue({ loggedIn: false }); + + await act(async () => { + render(); + }); + + const loginButton = await screen.findByRole("button", { name: /login/i }); + await waitFor(() => expect(mockGetAuthStatus).toHaveBeenCalled()); + await waitFor(() => expect(loginButton).toBeEnabled()); + + await userEvent.click(loginButton); + + expect(mockGetExternalLoginUrl).toHaveBeenCalledTimes(1); + expect(window.open).toHaveBeenCalledWith(testUrl); + }); + + it("shows logged-in menu and logs out", async () => { + mockGetAuthStatus + .mockResolvedValueOnce({ loggedIn: true, loggedInAs: "Lex User" }) + .mockResolvedValueOnce({ loggedIn: false }); + + const onStatusChange = jest.fn(); + + await act(async () => { + render(); + }); + + const userButton = await screen.findByRole("button", { + name: "Lex User", + }); + + await userEvent.click(userButton); + + const logoutItem = await screen.findByRole("menuitem", { name: /logout/i }); + + await userEvent.click(logoutItem); + + expect(mockGetExternalLoginUrl).not.toHaveBeenCalled(); + expect(mockLogoutCurrentUser).toHaveBeenCalledTimes(1); + expect(onStatusChange).toHaveBeenCalledWith("logged-out"); + }); +}); From f8090fb1d272cc85cfcad5c7a8c288b0130e03a9 Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Tue, 10 Feb 2026 14:30:07 -0500 Subject: [PATCH 02/38] Add Lexbox login to CreateProject --- .vscode/settings.json | 1 + src/components/ProjectScreen/CreateProject.tsx | 16 +++++++++------- 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/.vscode/settings.json b/.vscode/settings.json index 554e206347..6ef755f219 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -59,6 +59,7 @@ "langtags", "ldml", "letsencrypt", + "Lexbox", "Linq", "maint", "Memberwise", diff --git a/src/components/ProjectScreen/CreateProject.tsx b/src/components/ProjectScreen/CreateProject.tsx index dc6320085e..d3d95077c1 100644 --- a/src/components/ProjectScreen/CreateProject.tsx +++ b/src/components/ProjectScreen/CreateProject.tsx @@ -1,5 +1,6 @@ import { Cancel } from "@mui/icons-material"; import { + Box, Card, CardContent, Grid2, @@ -32,6 +33,7 @@ import { useAppDispatch } from "rootRedux/hooks"; import theme from "types/theme"; import { newWritingSystem } from "types/writingSystem"; import { NormalizedTextField } from "utilities/fontComponents"; +import LexboxLogin from "components/Lexbox/LexboxLogin"; export enum CreateProjectTextId { Create = "createProject.create", @@ -234,12 +236,7 @@ export default function CreateProject(): ReactElement { /> {/* File upload */} -
+ )} -
+ + + {/* Login to Lexbox to select a project. */} + + + {/* Don't render language pickers until project creation begins. */} {!!(name || languageData || vernLang.name || analysisLang.name) && ( From 24822a8219d066a7ff433af6b938ade3884cf1ca Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Tue, 10 Feb 2026 15:24:56 -0500 Subject: [PATCH 03/38] Add AuthStatus handling --- .../Controllers/AuthControllerTests.cs | 107 ++++++++++ Backend/Controllers/AuthController.cs | 80 +++++-- Backend/Interfaces/ILexboxAuthService.cs | 5 +- Backend/Models/AuthStatus.cs | 7 +- Backend/Models/LexboxAuthResult.cs | 8 + Backend/Models/LexboxAuthUser.cs | 8 + Backend/Services/LexboxAuthService.cs | 201 +++++++++++++++++- Backend/appsettings.json | 2 +- 8 files changed, 391 insertions(+), 27 deletions(-) create mode 100644 Backend.Tests/Controllers/AuthControllerTests.cs create mode 100644 Backend/Models/LexboxAuthResult.cs create mode 100644 Backend/Models/LexboxAuthUser.cs diff --git a/Backend.Tests/Controllers/AuthControllerTests.cs b/Backend.Tests/Controllers/AuthControllerTests.cs new file mode 100644 index 0000000000..a857c596ae --- /dev/null +++ b/Backend.Tests/Controllers/AuthControllerTests.cs @@ -0,0 +1,107 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Backend.Tests.Mocks; +using BackendFramework.Controllers; +using BackendFramework.Interfaces; +using BackendFramework.Models; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Configuration; +using NUnit.Framework; + +namespace Backend.Tests.Controllers +{ + internal sealed class AuthControllerTests : IDisposable + { + private PermissionServiceMock _permissionService = null!; + private LexboxAuthServiceMock _lexboxAuthService = null!; + private AuthController _controller = null!; + + public void Dispose() + { + _controller?.Dispose(); + GC.SuppressFinalize(this); + } + + [SetUp] + public void Setup() + { + _permissionService = new PermissionServiceMock(); + _lexboxAuthService = new LexboxAuthServiceMock(); + var configValues = new Dictionary + { + { "LexboxAuth:PostLoginRedirect", "/" }, + }; + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(configValues) + .Build(); + _controller = new AuthController(_permissionService, _lexboxAuthService, configuration); + } + + [Test] + public void GetAuthStatusUnauthorizedReturnsForbid() + { + _controller.ControllerContext.HttpContext = PermissionServiceMock.UnauthorizedHttpContext(); + + var result = _controller.GetAuthStatus(); + + Assert.That(result, Is.InstanceOf()); + } + + [Test] + public void GetAuthStatusReturnsLexboxUserWhenLoggedIn() + { + var context = PermissionServiceMock.HttpContextWithUserId("user-1"); + context.Request.Headers["Cookie"] = "lexbox_session_id=session-1"; + _controller.ControllerContext.HttpContext = context; + _lexboxAuthService.LoggedInUser = new LexboxAuthUser { UserId = "lex-1", DisplayName = "Lex User" }; + + var result = _controller.GetAuthStatus(); + + Assert.That(result, Is.InstanceOf()); + var payload = ((OkObjectResult)result).Value as AuthStatus; + Assert.That(payload, Is.Not.Null); + Assert.That(payload!.LoggedIn, Is.True); + Assert.That(payload.LoggedInAs, Is.EqualTo("Lex User")); + Assert.That(payload.UserId, Is.EqualTo("lex-1")); + } + + [Test] + public void CompleteLexboxLoginRedirectsWhenReturnUrlPresent() + { + _controller.ControllerContext.HttpContext = PermissionServiceMock.HttpContextWithUserId("user-1"); + _lexboxAuthService.CompleteResult = new LexboxAuthResult + { + User = new LexboxAuthUser { UserId = "lex-1", DisplayName = "Lex User" }, + ReturnUrl = "/after-login", + }; + + var result = _controller.CompleteLexboxLogin("code", "state").Result; + + Assert.That(result, Is.InstanceOf()); + Assert.That(((LocalRedirectResult)result).Url, Is.EqualTo("/after-login")); + } + + private sealed class LexboxAuthServiceMock : ILexboxAuthService + { + public LexboxAuthUser? LoggedInUser { get; set; } + public LexboxAuthResult? CompleteResult { get; set; } + + public LexboxLoginUrl CreateLoginUrl(HttpRequest request, string sessionId, string? returnUrl) + { + return new LexboxLoginUrl { Url = "https://example.test/login" }; + } + + public Task CompleteLoginAsync(HttpRequest request, string code, string state) + { + return Task.FromResult(CompleteResult); + } + + public LexboxAuthUser? GetLoggedInUser(string? sessionId) + { + return LoggedInUser; + } + } + } +} diff --git a/Backend/Controllers/AuthController.cs b/Backend/Controllers/AuthController.cs index aa05cb064a..916ee4ccb9 100644 --- a/Backend/Controllers/AuthController.cs +++ b/Backend/Controllers/AuthController.cs @@ -3,55 +3,68 @@ using BackendFramework.Interfaces; using BackendFramework.Models; using BackendFramework.Otel; -using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Configuration; namespace BackendFramework.Controllers { [Produces("application/json")] [Route("v1/auth")] public class AuthController( - IUserRepository userRepo, IPermissionService permissionService, - ILexboxAuthService lexboxAuthService) : Controller + ILexboxAuthService lexboxAuthService, + IConfiguration configuration) : Controller { - private readonly IUserRepository _userRepo = userRepo; private readonly IPermissionService _permissionService = permissionService; private readonly ILexboxAuthService _lexboxAuthService = lexboxAuthService; + private readonly IConfiguration _configuration = configuration; private const string otelTagName = "otel.AuthController"; + private const string LexboxSessionCookieName = "lexbox_session_id"; + private const string PostLoginRedirectConfigKey = "LexboxAuth:PostLoginRedirect"; /// Gets authentication status for the current request. - [AllowAnonymous] [HttpGet("status", Name = "GetAuthStatus")] [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(AuthStatus))] - public async Task GetAuthStatus() + public IActionResult GetAuthStatus() { using var activity = OtelService.StartActivityWithTag(otelTagName, "getting auth status"); - if (!_permissionService.IsCurrentUserAuthenticated(HttpContext)) { - return Ok(AuthStatus.LoggedOut()); + return Forbid(); } - - var userId = _permissionService.GetUserId(HttpContext); - var user = await _userRepo.GetUser(userId); - return user is null ? Ok(AuthStatus.LoggedOut()) : Ok(AuthStatus.LoggedInUser(user)); + var sessionId = Request.Cookies[LexboxSessionCookieName]; + var user = _lexboxAuthService.GetLoggedInUser(sessionId); + return user is null ? Ok(AuthStatus.LoggedOut()) : Ok(AuthStatus.LoggedInLexboxUser(user)); } /// Generates a Lexbox login URL for OIDC sign-in. - [AllowAnonymous] [HttpGet("lexbox/login-url", Name = "GetLexboxLoginUrl")] [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(LexboxLoginUrl))] [ProducesResponseType(StatusCodes.Status500InternalServerError)] - public IActionResult GetLexboxLoginUrl() + public IActionResult GetLexboxLoginUrl([FromQuery] string? returnUrl = null) { using var activity = OtelService.StartActivityWithTag(otelTagName, "getting lexbox login url"); + if (!_permissionService.IsCurrentUserAuthenticated(HttpContext)) + { + return Forbid(); + } try { - var result = _lexboxAuthService.CreateLoginUrl(Request); + var sessionId = Guid.NewGuid().ToString("N"); + Response.Cookies.Append(LexboxSessionCookieName, sessionId, new CookieOptions + { + HttpOnly = true, + SameSite = SameSiteMode.Lax, + Secure = Request.IsHttps, + IsEssential = true, + Path = "/", + }); + var normalizedReturnUrl = NormalizeReturnUrl(returnUrl) ?? + NormalizeReturnUrl(_configuration[PostLoginRedirectConfigKey]); + var result = _lexboxAuthService.CreateLoginUrl(Request, sessionId, normalizedReturnUrl); return Ok(result); } catch (InvalidOperationException ex) @@ -59,5 +72,42 @@ public IActionResult GetLexboxLoginUrl() return Problem(ex.Message, statusCode: StatusCodes.Status500InternalServerError); } } + + /// Completes the Lexbox OAuth login and stores the login status. + [HttpGet("/api/auth/oauth-callback", Name = "LexboxOauthCallback")] + [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(AuthStatus))] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + public async Task CompleteLexboxLogin([FromQuery] string? code, [FromQuery] string? state) + { + using var activity = OtelService.StartActivityWithTag(otelTagName, "completing lexbox login"); + if (!_permissionService.IsCurrentUserAuthenticated(HttpContext)) + { + return Forbid(); + } + + if (string.IsNullOrWhiteSpace(code) || string.IsNullOrWhiteSpace(state)) + { + return BadRequest("Missing code or state."); + } + + var result = await _lexboxAuthService.CompleteLoginAsync(Request, code, state); + if (result?.User is null) + { + return Ok(AuthStatus.LoggedOut()); + } + + var redirectUrl = NormalizeReturnUrl(result.ReturnUrl) + ?? NormalizeReturnUrl(_configuration[PostLoginRedirectConfigKey]); + return redirectUrl is null + ? Ok(AuthStatus.LoggedInLexboxUser(result.User)) + : LocalRedirect(redirectUrl); + } + + private static string? NormalizeReturnUrl(string? url) + { + if (string.IsNullOrWhiteSpace(url)) return null; + if (!Uri.TryCreate(url, UriKind.RelativeOrAbsolute, out var uri)) return null; + return uri.IsAbsoluteUri ? uri.PathAndQuery : uri.ToString(); + } } } diff --git a/Backend/Interfaces/ILexboxAuthService.cs b/Backend/Interfaces/ILexboxAuthService.cs index 65c9e5484a..9e81e5621e 100644 --- a/Backend/Interfaces/ILexboxAuthService.cs +++ b/Backend/Interfaces/ILexboxAuthService.cs @@ -1,10 +1,13 @@ using BackendFramework.Models; using Microsoft.AspNetCore.Http; +using System.Threading.Tasks; namespace BackendFramework.Interfaces { public interface ILexboxAuthService { - LexboxLoginUrl CreateLoginUrl(HttpRequest request); + LexboxLoginUrl CreateLoginUrl(HttpRequest request, string sessionId, string? returnUrl); + Task CompleteLoginAsync(HttpRequest request, string code, string state); + LexboxAuthUser? GetLoggedInUser(string? sessionId); } } diff --git a/Backend/Models/AuthStatus.cs b/Backend/Models/AuthStatus.cs index 256c111e65..fb8e7917b9 100644 --- a/Backend/Models/AuthStatus.cs +++ b/Backend/Models/AuthStatus.cs @@ -13,14 +13,13 @@ public class AuthStatus UserId = null, }; - public static AuthStatus LoggedInUser(User user) + public static AuthStatus LoggedInLexboxUser(LexboxAuthUser user) { - var displayName = string.IsNullOrWhiteSpace(user.Name) ? user.Username : user.Name; return new AuthStatus { LoggedIn = true, - LoggedInAs = displayName, - UserId = user.Id, + LoggedInAs = user.DisplayName, + UserId = user.UserId, }; } } diff --git a/Backend/Models/LexboxAuthResult.cs b/Backend/Models/LexboxAuthResult.cs new file mode 100644 index 0000000000..a9a61dbec2 --- /dev/null +++ b/Backend/Models/LexboxAuthResult.cs @@ -0,0 +1,8 @@ +namespace BackendFramework.Models +{ + public class LexboxAuthResult + { + public LexboxAuthUser? User { get; init; } + public string? ReturnUrl { get; init; } + } +} diff --git a/Backend/Models/LexboxAuthUser.cs b/Backend/Models/LexboxAuthUser.cs new file mode 100644 index 0000000000..47e91b5db3 --- /dev/null +++ b/Backend/Models/LexboxAuthUser.cs @@ -0,0 +1,8 @@ +namespace BackendFramework.Models +{ + public class LexboxAuthUser + { + public string? UserId { get; init; } + public string? DisplayName { get; init; } + } +} diff --git a/Backend/Services/LexboxAuthService.cs b/Backend/Services/LexboxAuthService.cs index 22314ebdb1..507f285658 100644 --- a/Backend/Services/LexboxAuthService.cs +++ b/Backend/Services/LexboxAuthService.cs @@ -1,7 +1,14 @@ using System; using System.Collections.Concurrent; using System.Collections.Generic; +using System.IdentityModel.Tokens.Jwt; +using System.Linq; +using System.Net.Http; +using System.Net.Http.Headers; using System.Security.Cryptography; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; using BackendFramework.Interfaces; using BackendFramework.Models; using Microsoft.AspNetCore.Http; @@ -10,14 +17,22 @@ namespace BackendFramework.Services { - public class LexboxAuthService(IConfiguration configuration) : ILexboxAuthService + public class LexboxAuthService(IConfiguration configuration, IHttpClientFactory httpClientFactory) + : ILexboxAuthService, IDisposable { private readonly IConfiguration _configuration = configuration; + private readonly IHttpClientFactory _httpClientFactory = httpClientFactory; private readonly ConcurrentDictionary _stateStore = new(); + private readonly ConcurrentDictionary _sessionStore = new(); + private readonly SemaphoreSlim _openIdConfigLock = new(1, 1); + private OpenIdConfiguration? _openIdConfiguration; + private DateTimeOffset _openIdConfigExpiresAt = DateTimeOffset.MinValue; private static readonly TimeSpan StateTtl = TimeSpan.FromMinutes(15); + private static readonly TimeSpan OpenIdConfigTtl = TimeSpan.FromHours(1); + private static readonly TimeSpan DefaultTokenLifetime = TimeSpan.FromHours(1); - public LexboxLoginUrl CreateLoginUrl(HttpRequest request) + public LexboxLoginUrl CreateLoginUrl(HttpRequest request, string sessionId, string? returnUrl) { var settings = GetSettings(); var state = CreateState(); @@ -25,7 +40,7 @@ public LexboxLoginUrl CreateLoginUrl(HttpRequest request) var codeChallenge = CreateCodeChallenge(codeVerifier); CleanupExpiredStates(); - _stateStore[state] = new LexboxAuthState(codeVerifier, DateTimeOffset.UtcNow); + _stateStore[state] = new LexboxAuthState(codeVerifier, DateTimeOffset.UtcNow, sessionId, returnUrl); var redirectUri = BuildRedirectUri(request); var query = new Dictionary @@ -46,12 +61,52 @@ public LexboxLoginUrl CreateLoginUrl(HttpRequest request) ["haschrome"] = "1", }; - var returnUrl = QueryHelpers.AddQueryString("/api/oauth/open-id-auth", query); - var loginUrl = $"{settings.BaseUrl.TrimEnd('/')}/login?ReturnUrl={Uri.EscapeDataString(returnUrl)}"; + var loginReturnUrl = QueryHelpers.AddQueryString("/api/oauth/open-id-auth", query); + var loginUrl = $"{settings.BaseUrl.TrimEnd('/')}/login?ReturnUrl={Uri.EscapeDataString(loginReturnUrl)}"; return new LexboxLoginUrl { Url = loginUrl }; } + public LexboxAuthUser? GetLoggedInUser(string? sessionId) + { + if (string.IsNullOrWhiteSpace(sessionId)) return null; + if (!_sessionStore.TryGetValue(sessionId, out var session)) return null; + if (session.ExpiresAt <= DateTimeOffset.UtcNow) + { + _sessionStore.TryRemove(sessionId, out _); + return null; + } + + return session.User; + } + + public async Task CompleteLoginAsync(HttpRequest request, string code, string state) + { + CleanupExpiredStates(); + if (!_stateStore.TryRemove(state, out var pending)) return null; + + var settings = GetSettings(); + var redirectUri = BuildRedirectUri(request); + var httpClient = _httpClientFactory.CreateClient(); + var openIdConfig = await GetOpenIdConfigurationAsync(httpClient, settings.BaseUrl); + var tokenResponse = await ExchangeCodeForTokenAsync(httpClient, + openIdConfig.TokenEndpoint, + settings.ClientId, + code, + redirectUri, + pending.CodeVerifier); + if (tokenResponse is null) return new LexboxAuthResult { User = null, ReturnUrl = null }; + + var user = GetUserFromIdToken(tokenResponse.IdToken) + ?? await GetUserFromUserInfoAsync(httpClient, openIdConfig.UserInfoEndpoint, tokenResponse.AccessToken); + if (user is null) return new LexboxAuthResult { User = null, ReturnUrl = null }; + + var expiresInSeconds = tokenResponse.ExpiresIn ?? (int)DefaultTokenLifetime.TotalSeconds; + var expiresAt = DateTimeOffset.UtcNow.AddSeconds(expiresInSeconds); + _sessionStore[pending.SessionId] = new LexboxAuthSession(user, expiresAt, tokenResponse.AccessToken); + return new LexboxAuthResult { User = user, ReturnUrl = pending.ReturnUrl }; + } + private static string BuildRedirectUri(HttpRequest request) { var pathBase = request.PathBase.HasValue ? request.PathBase.Value : string.Empty; @@ -121,7 +176,13 @@ private static string Base64UrlEncode(ReadOnlySpan data) .Replace('/', '_'); } - private sealed record LexboxAuthState(string CodeVerifier, DateTimeOffset CreatedAt); + private sealed record LexboxAuthState(string CodeVerifier, DateTimeOffset CreatedAt, string SessionId, string? ReturnUrl); + + private sealed record LexboxAuthSession(LexboxAuthUser User, DateTimeOffset ExpiresAt, string? AccessToken); + + private sealed record OpenIdConfiguration(string TokenEndpoint, string? UserInfoEndpoint); + + private sealed record TokenResponse(string? AccessToken, string? IdToken, int? ExpiresIn); private sealed class LexboxAuthSettings { @@ -133,5 +194,133 @@ private sealed class LexboxAuthSettings public string ClientVersion { get; set; } = ""; public string ClientOs { get; set; } = ""; } + + public void Dispose() + { + _openIdConfigLock.Dispose(); + GC.SuppressFinalize(this); + } + + private async Task GetOpenIdConfigurationAsync(HttpClient httpClient, string baseUrl) + { + if (_openIdConfiguration is not null && _openIdConfigExpiresAt > DateTimeOffset.UtcNow) + { + return _openIdConfiguration; + } + + await _openIdConfigLock.WaitAsync(); + try + { + if (_openIdConfiguration is not null && _openIdConfigExpiresAt > DateTimeOffset.UtcNow) + { + return _openIdConfiguration; + } + + var configUrl = $"{baseUrl.TrimEnd('/')}/.well-known/openid-configuration"; + using var response = await httpClient.GetAsync(configUrl); + response.EnsureSuccessStatusCode(); + var payload = await response.Content.ReadAsStringAsync(); + using var document = JsonDocument.Parse(payload); + var root = document.RootElement; + var tokenEndpoint = root.GetProperty("token_endpoint").GetString() ?? + throw new InvalidOperationException("Token endpoint missing from discovery document."); + var userInfoEndpoint = root.TryGetProperty("userinfo_endpoint", out var userInfoProperty) + ? userInfoProperty.GetString() + : null; + _openIdConfiguration = new OpenIdConfiguration(tokenEndpoint, userInfoEndpoint); + _openIdConfigExpiresAt = DateTimeOffset.UtcNow.Add(OpenIdConfigTtl); + return _openIdConfiguration; + } + finally + { + _openIdConfigLock.Release(); + } + } + + private static async Task ExchangeCodeForTokenAsync(HttpClient httpClient, + string tokenEndpoint, + string clientId, + string code, + string redirectUri, + string codeVerifier) + { + using var request = new HttpRequestMessage(HttpMethod.Post, tokenEndpoint) + { + Content = new FormUrlEncodedContent(new Dictionary + { + ["grant_type"] = "authorization_code", + ["client_id"] = clientId, + ["code"] = code, + ["redirect_uri"] = redirectUri, + ["code_verifier"] = codeVerifier, + }) + }; + + using var response = await httpClient.SendAsync(request); + if (!response.IsSuccessStatusCode) return null; + var payload = await response.Content.ReadAsStringAsync(); + using var document = JsonDocument.Parse(payload); + var root = document.RootElement; + var accessToken = root.TryGetProperty("access_token", out var accessTokenProperty) + ? accessTokenProperty.GetString() + : null; + var idToken = root.TryGetProperty("id_token", out var idTokenProperty) + ? idTokenProperty.GetString() + : null; + var expiresIn = root.TryGetProperty("expires_in", out var expiresProperty) + ? expiresProperty.GetInt32() + : (int?)null; + return new TokenResponse(accessToken, idToken, expiresIn); + } + + private static LexboxAuthUser? GetUserFromIdToken(string? idToken) + { + if (string.IsNullOrWhiteSpace(idToken)) return null; + try + { + var handler = new JwtSecurityTokenHandler(); + var token = handler.ReadJwtToken(idToken); + var userId = GetClaimValue(token, "sub"); + var displayName = GetClaimValue(token, "preferred_username") + ?? GetClaimValue(token, "email") + ?? GetClaimValue(token, "name") + ?? GetClaimValue(token, "upn") + ?? userId; + if (string.IsNullOrWhiteSpace(displayName) && string.IsNullOrWhiteSpace(userId)) return null; + return new LexboxAuthUser { UserId = userId, DisplayName = displayName }; + } + catch (Exception) + { + return null; + } + } + + private static string? GetClaimValue(JwtSecurityToken token, string claimType) + { + return token.Claims.FirstOrDefault(claim => string.Equals(claim.Type, claimType, StringComparison.OrdinalIgnoreCase))?.Value; + } + + private static async Task GetUserFromUserInfoAsync(HttpClient httpClient, + string? userInfoEndpoint, + string? accessToken) + { + if (string.IsNullOrWhiteSpace(userInfoEndpoint) || string.IsNullOrWhiteSpace(accessToken)) return null; + using var request = new HttpRequestMessage(HttpMethod.Get, userInfoEndpoint); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken); + using var response = await httpClient.SendAsync(request); + if (!response.IsSuccessStatusCode) return null; + var payload = await response.Content.ReadAsStringAsync(); + using var document = JsonDocument.Parse(payload); + var root = document.RootElement; + var userId = root.TryGetProperty("sub", out var subProperty) ? subProperty.GetString() : null; + var displayName = root.TryGetProperty("preferred_username", out var preferredProperty) + ? preferredProperty.GetString() + : null; + displayName ??= root.TryGetProperty("email", out var emailProperty) ? emailProperty.GetString() : null; + displayName ??= root.TryGetProperty("name", out var nameProperty) ? nameProperty.GetString() : null; + displayName ??= userId; + if (string.IsNullOrWhiteSpace(displayName) && string.IsNullOrWhiteSpace(userId)) return null; + return new LexboxAuthUser { UserId = userId, DisplayName = displayName }; + } } } diff --git a/Backend/appsettings.json b/Backend/appsettings.json index 11e8dff19b..c3faa1405b 100644 --- a/Backend/appsettings.json +++ b/Backend/appsettings.json @@ -11,7 +11,7 @@ }, "LexboxAuth": { "BaseUrl": "https://lexbox.org", - "ClientId": "", + "ClientId": "becf2856-0690-434b-b192-a4032b72067f", "Scope": "profile openid offline_access sendandreceive", "Prompt": "select_account", "ClientSku": "TheCombine", From aa4e76ee7164f43467108259f59fe4ed0d64201a Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Tue, 10 Feb 2026 15:45:17 -0500 Subject: [PATCH 04/38] Fix frontend lint, tests --- src/components/ProjectScreen/CreateProject.tsx | 2 +- src/components/ProjectScreen/tests/CreateProject.test.tsx | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/components/ProjectScreen/CreateProject.tsx b/src/components/ProjectScreen/CreateProject.tsx index d3d95077c1..3039b48038 100644 --- a/src/components/ProjectScreen/CreateProject.tsx +++ b/src/components/ProjectScreen/CreateProject.tsx @@ -25,6 +25,7 @@ import { projectDuplicateCheck, uploadLiftAndGetWritingSystems } from "backend"; import FileInputButton from "components/Buttons/FileInputButton"; import LoadingDoneButton from "components/Buttons/LoadingDoneButton"; import LanguagePicker from "components/LanguagePicker"; +import LexboxLogin from "components/Lexbox/LexboxLogin"; import { asyncCreateProject, asyncFinishProject, @@ -33,7 +34,6 @@ import { useAppDispatch } from "rootRedux/hooks"; import theme from "types/theme"; import { newWritingSystem } from "types/writingSystem"; import { NormalizedTextField } from "utilities/fontComponents"; -import LexboxLogin from "components/Lexbox/LexboxLogin"; export enum CreateProjectTextId { Create = "createProject.create", diff --git a/src/components/ProjectScreen/tests/CreateProject.test.tsx b/src/components/ProjectScreen/tests/CreateProject.test.tsx index bebd15c1a3..501d8551f4 100644 --- a/src/components/ProjectScreen/tests/CreateProject.test.tsx +++ b/src/components/ProjectScreen/tests/CreateProject.test.tsx @@ -30,6 +30,7 @@ jest.mock("components/LanguagePicker", () => ({ })); jest.mock("backend", () => ({ + getAuthStatus: jest.fn(), projectDuplicateCheck: () => mockProjectDuplicateCheck(), uploadLiftAndGetWritingSystems: () => mockUploadLiftAndGetWritingSystems(), })); From 723b83fbe53b0628098ad752ae05d2fb62ea8f86 Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Tue, 10 Feb 2026 16:49:28 -0500 Subject: [PATCH 05/38] Adjust API routes --- .../Controllers/AuthControllerTests.cs | 17 +-- Backend/Controllers/AuthController.cs | 18 +-- Backend/Services/LexboxAuthService.cs | 3 +- Backend/appsettings.json | 6 +- src/api/api/auth-api.ts | 136 +++++++++++++++++- src/components/Lexbox/LexboxLogin.tsx | 1 + 6 files changed, 157 insertions(+), 24 deletions(-) diff --git a/Backend.Tests/Controllers/AuthControllerTests.cs b/Backend.Tests/Controllers/AuthControllerTests.cs index a857c596ae..cf35478959 100644 --- a/Backend.Tests/Controllers/AuthControllerTests.cs +++ b/Backend.Tests/Controllers/AuthControllerTests.cs @@ -14,8 +14,8 @@ namespace Backend.Tests.Controllers { internal sealed class AuthControllerTests : IDisposable { - private PermissionServiceMock _permissionService = null!; private LexboxAuthServiceMock _lexboxAuthService = null!; + private PermissionServiceMock _permissionService = null!; private AuthController _controller = null!; public void Dispose() @@ -27,16 +27,11 @@ public void Dispose() [SetUp] public void Setup() { - _permissionService = new PermissionServiceMock(); _lexboxAuthService = new LexboxAuthServiceMock(); - var configValues = new Dictionary - { - { "LexboxAuth:PostLoginRedirect", "/" }, - }; - var configuration = new ConfigurationBuilder() - .AddInMemoryCollection(configValues) - .Build(); - _controller = new AuthController(_permissionService, _lexboxAuthService, configuration); + _permissionService = new PermissionServiceMock(); + var configValues = new Dictionary { { "LexboxAuth:PostLoginRedirect", "/" } }; + var configuration = new ConfigurationBuilder().AddInMemoryCollection(configValues).Build(); + _controller = new AuthController(_lexboxAuthService, _permissionService, configuration); } [Test] @@ -90,7 +85,7 @@ private sealed class LexboxAuthServiceMock : ILexboxAuthService public LexboxLoginUrl CreateLoginUrl(HttpRequest request, string sessionId, string? returnUrl) { - return new LexboxLoginUrl { Url = "https://example.test/login" }; + return new LexboxLoginUrl { Url = "not-a-valid-url" }; } public Task CompleteLoginAsync(HttpRequest request, string code, string state) diff --git a/Backend/Controllers/AuthController.cs b/Backend/Controllers/AuthController.cs index 916ee4ccb9..f64d429820 100644 --- a/Backend/Controllers/AuthController.cs +++ b/Backend/Controllers/AuthController.cs @@ -1,5 +1,6 @@ using System; using System.Threading.Tasks; +using BackendFramework.Helper; using BackendFramework.Interfaces; using BackendFramework.Models; using BackendFramework.Otel; @@ -11,9 +12,7 @@ namespace BackendFramework.Controllers { [Produces("application/json")] [Route("v1/auth")] - public class AuthController( - IPermissionService permissionService, - ILexboxAuthService lexboxAuthService, + public class AuthController(ILexboxAuthService lexboxAuthService, IPermissionService permissionService, IConfiguration configuration) : Controller { private readonly IPermissionService _permissionService = permissionService; @@ -27,6 +26,7 @@ public class AuthController( /// Gets authentication status for the current request. [HttpGet("status", Name = "GetAuthStatus")] [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(AuthStatus))] + [ProducesResponseType(StatusCodes.Status403Forbidden)] public IActionResult GetAuthStatus() { using var activity = OtelService.StartActivityWithTag(otelTagName, "getting auth status"); @@ -40,10 +40,11 @@ public IActionResult GetAuthStatus() } /// Generates a Lexbox login URL for OIDC sign-in. - [HttpGet("lexbox/login-url", Name = "GetLexboxLoginUrl")] + [HttpGet("lexbox-login-url", Name = "GetLexboxLoginUrl")] [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(LexboxLoginUrl))] + [ProducesResponseType(StatusCodes.Status403Forbidden)] [ProducesResponseType(StatusCodes.Status500InternalServerError)] - public IActionResult GetLexboxLoginUrl([FromQuery] string? returnUrl = null) + public IActionResult GetLexboxLoginUrl() { using var activity = OtelService.StartActivityWithTag(otelTagName, "getting lexbox login url"); if (!_permissionService.IsCurrentUserAuthenticated(HttpContext)) @@ -62,8 +63,8 @@ public IActionResult GetLexboxLoginUrl([FromQuery] string? returnUrl = null) IsEssential = true, Path = "/", }); - var normalizedReturnUrl = NormalizeReturnUrl(returnUrl) ?? - NormalizeReturnUrl(_configuration[PostLoginRedirectConfigKey]); + var normalizedReturnUrl = NormalizeReturnUrl(_configuration[PostLoginRedirectConfigKey]) + ?? NormalizeReturnUrl(Domain.FrontendDomain); var result = _lexboxAuthService.CreateLoginUrl(Request, sessionId, normalizedReturnUrl); return Ok(result); } @@ -74,9 +75,10 @@ public IActionResult GetLexboxLoginUrl([FromQuery] string? returnUrl = null) } /// Completes the Lexbox OAuth login and stores the login status. - [HttpGet("/api/auth/oauth-callback", Name = "LexboxOauthCallback")] + [HttpGet("oauth-callback", Name = "LexboxOauthCallback")] [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(AuthStatus))] [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] public async Task CompleteLexboxLogin([FromQuery] string? code, [FromQuery] string? state) { using var activity = OtelService.StartActivityWithTag(otelTagName, "completing lexbox login"); diff --git a/Backend/Services/LexboxAuthService.cs b/Backend/Services/LexboxAuthService.cs index 507f285658..27b97f9793 100644 --- a/Backend/Services/LexboxAuthService.cs +++ b/Backend/Services/LexboxAuthService.cs @@ -62,6 +62,7 @@ public LexboxLoginUrl CreateLoginUrl(HttpRequest request, string sessionId, stri }; var loginReturnUrl = QueryHelpers.AddQueryString("/api/oauth/open-id-auth", query); + Console.WriteLine($"Return URL: {loginReturnUrl}"); var loginUrl = $"{settings.BaseUrl.TrimEnd('/')}/login?ReturnUrl={Uri.EscapeDataString(loginReturnUrl)}"; return new LexboxLoginUrl { Url = loginUrl }; @@ -110,7 +111,7 @@ public LexboxLoginUrl CreateLoginUrl(HttpRequest request, string sessionId, stri private static string BuildRedirectUri(HttpRequest request) { var pathBase = request.PathBase.HasValue ? request.PathBase.Value : string.Empty; - return $"{request.Scheme}://{request.Host}{pathBase}/api/auth/oauth-callback"; + return $"{request.Scheme}://{request.Host}{pathBase}/v1/auth/oauth-callback"; } private LexboxAuthSettings GetSettings() diff --git a/Backend/appsettings.json b/Backend/appsettings.json index c3faa1405b..f701f5ed9b 100644 --- a/Backend/appsettings.json +++ b/Backend/appsettings.json @@ -12,11 +12,11 @@ "LexboxAuth": { "BaseUrl": "https://lexbox.org", "ClientId": "becf2856-0690-434b-b192-a4032b72067f", - "Scope": "profile openid offline_access sendandreceive", - "Prompt": "select_account", + "ClientOs": "Microsoft Windows", "ClientSku": "TheCombine", "ClientVersion": "1.0", - "ClientOs": "Microsoft Windows" + "Prompt": "select_account", + "Scope": "profile openid offline_access sendandreceive" }, "AllowedHosts": "*" } diff --git a/src/api/api/auth-api.ts b/src/api/api/auth-api.ts index 532181ad7c..0ab0745598 100644 --- a/src/api/api/auth-api.ts +++ b/src/api/api/auth-api.ts @@ -90,7 +90,7 @@ export const AuthApiAxiosParamCreator = function ( * @throws {RequiredError} */ getLexboxLoginUrl: async (options: any = {}): Promise => { - const localVarPath = `/v1/auth/lexbox/login-url`; + const localVarPath = `/v1/auth/lexbox-login-url`; // use dummy base URL string because the URL constructor only accepts absolute URLs. const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); let baseOptions; @@ -115,6 +115,56 @@ export const AuthApiAxiosParamCreator = function ( ...options.headers, }; + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, + /** + * + * @param {string} [code] + * @param {string} [state] + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + lexboxOauthCallback: async ( + code?: string, + state?: string, + options: any = {} + ): Promise => { + const localVarPath = `/v1/auth/oauth-callback`; + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { + method: "GET", + ...baseOptions, + ...options, + }; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + if (code !== undefined) { + localVarQueryParameter["code"] = code; + } + + if (state !== undefined) { + localVarQueryParameter["state"] = state; + } + + setSearchParams(localVarUrlObj, localVarQueryParameter, options.query); + let headersFromBaseOptions = + baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = { + ...localVarHeaderParameter, + ...headersFromBaseOptions, + ...options.headers, + }; + return { url: toPathString(localVarUrlObj), options: localVarRequestOptions, @@ -168,6 +218,33 @@ export const AuthApiFp = function (configuration?: Configuration) { configuration ); }, + /** + * + * @param {string} [code] + * @param {string} [state] + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async lexboxOauthCallback( + code?: string, + state?: string, + options?: any + ): Promise< + (axios?: AxiosInstance, basePath?: string) => AxiosPromise + > { + const localVarAxiosArgs = + await localVarAxiosParamCreator.lexboxOauthCallback( + code, + state, + options + ); + return createRequestFunction( + localVarAxiosArgs, + globalAxios, + BASE_PATH, + configuration + ); + }, }; }; @@ -202,9 +279,46 @@ export const AuthApiFactory = function ( .getLexboxLoginUrl(options) .then((request) => request(axios, basePath)); }, + /** + * + * @param {string} [code] + * @param {string} [state] + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + lexboxOauthCallback( + code?: string, + state?: string, + options?: any + ): AxiosPromise { + return localVarFp + .lexboxOauthCallback(code, state, options) + .then((request) => request(axios, basePath)); + }, }; }; +/** + * Request parameters for lexboxOauthCallback operation in AuthApi. + * @export + * @interface AuthApiLexboxOauthCallbackRequest + */ +export interface AuthApiLexboxOauthCallbackRequest { + /** + * + * @type {string} + * @memberof AuthApiLexboxOauthCallback + */ + readonly code?: string; + + /** + * + * @type {string} + * @memberof AuthApiLexboxOauthCallback + */ + readonly state?: string; +} + /** * AuthApi - object-oriented interface * @export @@ -235,4 +349,24 @@ export class AuthApi extends BaseAPI { .getLexboxLoginUrl(options) .then((request) => request(this.axios, this.basePath)); } + + /** + * + * @param {AuthApiLexboxOauthCallbackRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof AuthApi + */ + public lexboxOauthCallback( + requestParameters: AuthApiLexboxOauthCallbackRequest = {}, + options?: any + ) { + return AuthApiFp(this.configuration) + .lexboxOauthCallback( + requestParameters.code, + requestParameters.state, + options + ) + .then((request) => request(this.axios, this.basePath)); + } } diff --git a/src/components/Lexbox/LexboxLogin.tsx b/src/components/Lexbox/LexboxLogin.tsx index c960c6ac6e..0d92c066e6 100644 --- a/src/components/Lexbox/LexboxLogin.tsx +++ b/src/components/Lexbox/LexboxLogin.tsx @@ -46,6 +46,7 @@ export default function LexboxLogin(props: LexboxLoginProps): ReactElement { setActionLoading(true); try { const url = await getExternalLoginUrl(); + console.info("Opening Lexbox login URL:", url); if (url) { window.open(url); } else { From 79eb3a44075533a5c6d829901a3c06701b3bf549 Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Wed, 18 Feb 2026 17:25:23 -0500 Subject: [PATCH 06/38] Remove Azure AD artifacts --- Backend/Services/LexboxAuthService.cs | 25 +++++++------------------ Backend/appsettings.json | 3 --- 2 files changed, 7 insertions(+), 21 deletions(-) diff --git a/Backend/Services/LexboxAuthService.cs b/Backend/Services/LexboxAuthService.cs index 27b97f9793..86073e2059 100644 --- a/Backend/Services/LexboxAuthService.cs +++ b/Backend/Services/LexboxAuthService.cs @@ -45,20 +45,15 @@ public LexboxLoginUrl CreateLoginUrl(HttpRequest request, string sessionId, stri var redirectUri = BuildRedirectUri(request); var query = new Dictionary { - ["scope"] = settings.Scope, - ["response_type"] = "code", + ["code_challenge"] = codeChallenge, + ["code_challenge_method"] = "S256", ["client_id"] = settings.ClientId, - ["redirect_uri"] = redirectUri, ["client-request-id"] = Guid.NewGuid().ToString(), - ["x-client-SKU"] = settings.ClientSku, - ["x-client-Ver"] = settings.ClientVersion, - ["x-client-OS"] = settings.ClientOs, ["prompt"] = settings.Prompt, - ["code_challenge"] = codeChallenge, - ["code_challenge_method"] = "S256", + ["redirect_uri"] = redirectUri, + ["response_type"] = "code", + ["scope"] = settings.Scope, ["state"] = state, - ["client_info"] = "1", - ["haschrome"] = "1", }; var loginReturnUrl = QueryHelpers.AddQueryString("/api/oauth/open-id-auth", query); @@ -127,12 +122,9 @@ private LexboxAuthSettings GetSettings() { BaseUrl = baseUrl, ClientId = clientId, + Prompt = _configuration["LexboxAuth:Prompt"] ?? "select_account", Scope = _configuration["LexboxAuth:Scope"] ?? "profile openid offline_access sendandreceive", - Prompt = _configuration["LexboxAuth:Prompt"] ?? "select_account", - ClientSku = _configuration["LexboxAuth:ClientSku"] ?? "TheCombine", - ClientVersion = _configuration["LexboxAuth:ClientVersion"] ?? "1.0", - ClientOs = _configuration["LexboxAuth:ClientOs"] ?? Environment.OSVersion.ToString(), }; } @@ -189,11 +181,8 @@ private sealed class LexboxAuthSettings { public string BaseUrl { get; set; } = ""; public string ClientId { get; set; } = ""; - public string Scope { get; set; } = ""; public string Prompt { get; set; } = ""; - public string ClientSku { get; set; } = ""; - public string ClientVersion { get; set; } = ""; - public string ClientOs { get; set; } = ""; + public string Scope { get; set; } = ""; } public void Dispose() diff --git a/Backend/appsettings.json b/Backend/appsettings.json index f701f5ed9b..6a6db9c640 100644 --- a/Backend/appsettings.json +++ b/Backend/appsettings.json @@ -12,9 +12,6 @@ "LexboxAuth": { "BaseUrl": "https://lexbox.org", "ClientId": "becf2856-0690-434b-b192-a4032b72067f", - "ClientOs": "Microsoft Windows", - "ClientSku": "TheCombine", - "ClientVersion": "1.0", "Prompt": "select_account", "Scope": "profile openid offline_access sendandreceive" }, From 525194b0dd11b03f555d3c3c348368c222909777 Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Wed, 18 Feb 2026 17:51:59 -0500 Subject: [PATCH 07/38] Comment, trim, and style --- Backend/Controllers/AuthController.cs | 15 +++- Backend/Services/LexboxAuthService.cs | 113 ++++++++++++++++---------- 2 files changed, 80 insertions(+), 48 deletions(-) diff --git a/Backend/Controllers/AuthController.cs b/Backend/Controllers/AuthController.cs index f64d429820..8ed711b7df 100644 --- a/Backend/Controllers/AuthController.cs +++ b/Backend/Controllers/AuthController.cs @@ -34,6 +34,7 @@ public IActionResult GetAuthStatus() { return Forbid(); } + var sessionId = Request.Cookies[LexboxSessionCookieName]; var user = _lexboxAuthService.GetLoggedInUser(sessionId); return user is null ? Ok(AuthStatus.LoggedOut()) : Ok(AuthStatus.LoggedInLexboxUser(user)); @@ -58,10 +59,12 @@ public IActionResult GetLexboxLoginUrl() Response.Cookies.Append(LexboxSessionCookieName, sessionId, new CookieOptions { HttpOnly = true, - SameSite = SameSiteMode.Lax, - Secure = Request.IsHttps, IsEssential = true, Path = "/", + SameSite = SameSiteMode.Lax, + Secure = Request.IsHttps, + // Todo: Add MaxAge or Expires to use this cookie across settings. + // As of now, the cookie is generated but not actually used. }); var normalizedReturnUrl = NormalizeReturnUrl(_configuration[PostLoginRedirectConfigKey]) ?? NormalizeReturnUrl(Domain.FrontendDomain); @@ -107,8 +110,12 @@ public async Task CompleteLexboxLogin([FromQuery] string? code, [ private static string? NormalizeReturnUrl(string? url) { - if (string.IsNullOrWhiteSpace(url)) return null; - if (!Uri.TryCreate(url, UriKind.RelativeOrAbsolute, out var uri)) return null; + url = url?.Trim(); + if (string.IsNullOrEmpty(url) || !Uri.TryCreate(url, UriKind.RelativeOrAbsolute, out var uri)) + { + return null; + } + return uri.IsAbsoluteUri ? uri.PathAndQuery : uri.ToString(); } } diff --git a/Backend/Services/LexboxAuthService.cs b/Backend/Services/LexboxAuthService.cs index 86073e2059..cc9d304dff 100644 --- a/Backend/Services/LexboxAuthService.cs +++ b/Backend/Services/LexboxAuthService.cs @@ -65,8 +65,11 @@ public LexboxLoginUrl CreateLoginUrl(HttpRequest request, string sessionId, stri public LexboxAuthUser? GetLoggedInUser(string? sessionId) { - if (string.IsNullOrWhiteSpace(sessionId)) return null; - if (!_sessionStore.TryGetValue(sessionId, out var session)) return null; + if (string.IsNullOrWhiteSpace(sessionId) || !_sessionStore.TryGetValue(sessionId, out var session)) + { + return null; + } + if (session.ExpiresAt <= DateTimeOffset.UtcNow) { _sessionStore.TryRemove(sessionId, out _); @@ -79,23 +82,29 @@ public LexboxLoginUrl CreateLoginUrl(HttpRequest request, string sessionId, stri public async Task CompleteLoginAsync(HttpRequest request, string code, string state) { CleanupExpiredStates(); - if (!_stateStore.TryRemove(state, out var pending)) return null; + if (!_stateStore.TryRemove(state, out var pending)) + { + return null; + } var settings = GetSettings(); var redirectUri = BuildRedirectUri(request); var httpClient = _httpClientFactory.CreateClient(); var openIdConfig = await GetOpenIdConfigurationAsync(httpClient, settings.BaseUrl); - var tokenResponse = await ExchangeCodeForTokenAsync(httpClient, - openIdConfig.TokenEndpoint, - settings.ClientId, - code, - redirectUri, - pending.CodeVerifier); - if (tokenResponse is null) return new LexboxAuthResult { User = null, ReturnUrl = null }; + var tokenResponse = await ExchangeCodeForTokenAsync( + httpClient, openIdConfig.TokenEndpoint, settings.ClientId, code, redirectUri, pending.CodeVerifier); + if (tokenResponse is null) + { + return new LexboxAuthResult { User = null, ReturnUrl = null }; + } var user = GetUserFromIdToken(tokenResponse.IdToken) - ?? await GetUserFromUserInfoAsync(httpClient, openIdConfig.UserInfoEndpoint, tokenResponse.AccessToken); - if (user is null) return new LexboxAuthResult { User = null, ReturnUrl = null }; + ?? await GetUserFromUserInfoAsync( + httpClient, openIdConfig.UserInfoEndpoint, tokenResponse.AccessToken); + if (user is null) + { + return new LexboxAuthResult { User = null, ReturnUrl = null }; + } var expiresInSeconds = tokenResponse.ExpiresIn ?? (int)DefaultTokenLifetime.TotalSeconds; var expiresAt = DateTimeOffset.UtcNow.AddSeconds(expiresInSeconds); @@ -112,7 +121,7 @@ private static string BuildRedirectUri(HttpRequest request) private LexboxAuthSettings GetSettings() { var baseUrl = _configuration["LexboxAuth:BaseUrl"] ?? "https://lexbox.org"; - var clientId = _configuration["LexboxAuth:ClientId"] ?? string.Empty; + var clientId = _configuration["LexboxAuth:ClientId"]; if (string.IsNullOrWhiteSpace(clientId)) { throw new InvalidOperationException("LexboxAuth:ClientId must be configured."); @@ -123,8 +132,7 @@ private LexboxAuthSettings GetSettings() BaseUrl = baseUrl, ClientId = clientId, Prompt = _configuration["LexboxAuth:Prompt"] ?? "select_account", - Scope = _configuration["LexboxAuth:Scope"] - ?? "profile openid offline_access sendandreceive", + Scope = _configuration["LexboxAuth:Scope"] ?? "profile openid offline_access sendandreceive", }; } @@ -164,12 +172,11 @@ private void CleanupExpiredStates() private static string Base64UrlEncode(ReadOnlySpan data) { var base64 = Convert.ToBase64String(data); - return base64.TrimEnd('=') - .Replace('+', '-') - .Replace('/', '_'); + return base64.TrimEnd('=').Replace('+', '-').Replace('/', '_'); } - private sealed record LexboxAuthState(string CodeVerifier, DateTimeOffset CreatedAt, string SessionId, string? ReturnUrl); + private sealed record LexboxAuthState( + string CodeVerifier, DateTimeOffset CreatedAt, string SessionId, string? ReturnUrl); private sealed record LexboxAuthSession(LexboxAuthUser User, DateTimeOffset ExpiresAt, string? AccessToken); @@ -212,8 +219,8 @@ private async Task GetOpenIdConfigurationAsync(HttpClient h var payload = await response.Content.ReadAsStringAsync(); using var document = JsonDocument.Parse(payload); var root = document.RootElement; - var tokenEndpoint = root.GetProperty("token_endpoint").GetString() ?? - throw new InvalidOperationException("Token endpoint missing from discovery document."); + var tokenEndpoint = root.GetProperty("token_endpoint").GetString() + ?? throw new InvalidOperationException("Token endpoint missing from discovery document."); var userInfoEndpoint = root.TryGetProperty("userinfo_endpoint", out var userInfoProperty) ? userInfoProperty.GetString() : null; @@ -228,26 +235,26 @@ private async Task GetOpenIdConfigurationAsync(HttpClient h } private static async Task ExchangeCodeForTokenAsync(HttpClient httpClient, - string tokenEndpoint, - string clientId, - string code, - string redirectUri, - string codeVerifier) + string tokenEndpoint, string clientId, string code, string redirectUri, string codeVerifier) { using var request = new HttpRequestMessage(HttpMethod.Post, tokenEndpoint) { Content = new FormUrlEncodedContent(new Dictionary { - ["grant_type"] = "authorization_code", ["client_id"] = clientId, ["code"] = code, - ["redirect_uri"] = redirectUri, ["code_verifier"] = codeVerifier, + ["grant_type"] = "authorization_code", + ["redirect_uri"] = redirectUri, }) }; using var response = await httpClient.SendAsync(request); - if (!response.IsSuccessStatusCode) return null; + if (!response.IsSuccessStatusCode) + { + return null; + } + var payload = await response.Content.ReadAsStringAsync(); using var document = JsonDocument.Parse(payload); var root = document.RootElement; @@ -257,27 +264,34 @@ private async Task GetOpenIdConfigurationAsync(HttpClient h var idToken = root.TryGetProperty("id_token", out var idTokenProperty) ? idTokenProperty.GetString() : null; - var expiresIn = root.TryGetProperty("expires_in", out var expiresProperty) + int? expiresIn = root.TryGetProperty("expires_in", out var expiresProperty) ? expiresProperty.GetInt32() - : (int?)null; + : null; return new TokenResponse(accessToken, idToken, expiresIn); } private static LexboxAuthUser? GetUserFromIdToken(string? idToken) { - if (string.IsNullOrWhiteSpace(idToken)) return null; + if (string.IsNullOrWhiteSpace(idToken)) + { + return null; + } + try { var handler = new JwtSecurityTokenHandler(); var token = handler.ReadJwtToken(idToken); - var userId = GetClaimValue(token, "sub"); + var userId = GetClaimValue(token, "sub")?.Trim(); var displayName = GetClaimValue(token, "preferred_username") ?? GetClaimValue(token, "email") ?? GetClaimValue(token, "name") ?? GetClaimValue(token, "upn") ?? userId; - if (string.IsNullOrWhiteSpace(displayName) && string.IsNullOrWhiteSpace(userId)) return null; - return new LexboxAuthUser { UserId = userId, DisplayName = displayName }; + displayName = displayName?.Trim(); + + return string.IsNullOrEmpty(displayName) && string.IsNullOrEmpty(userId) + ? null + : new LexboxAuthUser { UserId = userId, DisplayName = displayName }; } catch (Exception) { @@ -287,30 +301,41 @@ private async Task GetOpenIdConfigurationAsync(HttpClient h private static string? GetClaimValue(JwtSecurityToken token, string claimType) { - return token.Claims.FirstOrDefault(claim => string.Equals(claim.Type, claimType, StringComparison.OrdinalIgnoreCase))?.Value; + return token.Claims.FirstOrDefault( + claim => string.Equals(claim.Type, claimType, StringComparison.OrdinalIgnoreCase))?.Value; } - private static async Task GetUserFromUserInfoAsync(HttpClient httpClient, - string? userInfoEndpoint, - string? accessToken) + private static async Task GetUserFromUserInfoAsync( + HttpClient httpClient, string? userInfoEndpoint, string? accessToken) { - if (string.IsNullOrWhiteSpace(userInfoEndpoint) || string.IsNullOrWhiteSpace(accessToken)) return null; + if (string.IsNullOrWhiteSpace(userInfoEndpoint) || string.IsNullOrWhiteSpace(accessToken)) + { + return null; + } + using var request = new HttpRequestMessage(HttpMethod.Get, userInfoEndpoint); request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken); using var response = await httpClient.SendAsync(request); - if (!response.IsSuccessStatusCode) return null; + if (!response.IsSuccessStatusCode) + { + return null; + } + var payload = await response.Content.ReadAsStringAsync(); using var document = JsonDocument.Parse(payload); var root = document.RootElement; - var userId = root.TryGetProperty("sub", out var subProperty) ? subProperty.GetString() : null; + var userId = root.TryGetProperty("sub", out var subProperty) ? subProperty.GetString()?.Trim() : null; var displayName = root.TryGetProperty("preferred_username", out var preferredProperty) ? preferredProperty.GetString() : null; displayName ??= root.TryGetProperty("email", out var emailProperty) ? emailProperty.GetString() : null; displayName ??= root.TryGetProperty("name", out var nameProperty) ? nameProperty.GetString() : null; displayName ??= userId; - if (string.IsNullOrWhiteSpace(displayName) && string.IsNullOrWhiteSpace(userId)) return null; - return new LexboxAuthUser { UserId = userId, DisplayName = displayName }; + displayName = displayName?.Trim(); + + return (string.IsNullOrEmpty(displayName) && string.IsNullOrEmpty(userId)) + ? null + : new LexboxAuthUser { UserId = userId, DisplayName = displayName }; } } } From 24c6bc8c23e6ae21e87f77efe1324049207a449b Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Thu, 19 Feb 2026 10:33:27 -0500 Subject: [PATCH 08/38] Change LexboxAuthSettings --- Backend/Services/LexboxAuthService.cs | 45 +++++++++++++-------------- 1 file changed, 22 insertions(+), 23 deletions(-) diff --git a/Backend/Services/LexboxAuthService.cs b/Backend/Services/LexboxAuthService.cs index cc9d304dff..4f5ab39f10 100644 --- a/Backend/Services/LexboxAuthService.cs +++ b/Backend/Services/LexboxAuthService.cs @@ -42,24 +42,22 @@ public LexboxLoginUrl CreateLoginUrl(HttpRequest request, string sessionId, stri CleanupExpiredStates(); _stateStore[state] = new LexboxAuthState(codeVerifier, DateTimeOffset.UtcNow, sessionId, returnUrl); - var redirectUri = BuildRedirectUri(request); var query = new Dictionary { ["code_challenge"] = codeChallenge, ["code_challenge_method"] = "S256", ["client_id"] = settings.ClientId, - ["client-request-id"] = Guid.NewGuid().ToString(), ["prompt"] = settings.Prompt, - ["redirect_uri"] = redirectUri, + ["redirect_uri"] = BuildRedirectUri(request), ["response_type"] = "code", ["scope"] = settings.Scope, ["state"] = state, }; var loginReturnUrl = QueryHelpers.AddQueryString("/api/oauth/open-id-auth", query); - Console.WriteLine($"Return URL: {loginReturnUrl}"); - var loginUrl = $"{settings.BaseUrl.TrimEnd('/')}/login?ReturnUrl={Uri.EscapeDataString(loginReturnUrl)}"; - + Console.WriteLine($"Return URL: {loginReturnUrl}"); // TODO: Remove or replace with proper logging + var loginUrl = QueryHelpers.AddQueryString(settings.LoginBaseUrl, "ReturnUrl", loginReturnUrl); + Console.WriteLine($"Login URL: {loginUrl}"); // TODO: Remove or replace with proper logging return new LexboxLoginUrl { Url = loginUrl }; } @@ -90,7 +88,7 @@ public LexboxLoginUrl CreateLoginUrl(HttpRequest request, string sessionId, stri var settings = GetSettings(); var redirectUri = BuildRedirectUri(request); var httpClient = _httpClientFactory.CreateClient(); - var openIdConfig = await GetOpenIdConfigurationAsync(httpClient, settings.BaseUrl); + var openIdConfig = await GetOpenIdConfigurationAsync(httpClient, settings.OpenIdConfigUrl); var tokenResponse = await ExchangeCodeForTokenAsync( httpClient, openIdConfig.TokenEndpoint, settings.ClientId, code, redirectUri, pending.CodeVerifier); if (tokenResponse is null) @@ -115,24 +113,25 @@ public LexboxLoginUrl CreateLoginUrl(HttpRequest request, string sessionId, stri private static string BuildRedirectUri(HttpRequest request) { var pathBase = request.PathBase.HasValue ? request.PathBase.Value : string.Empty; - return $"{request.Scheme}://{request.Host}{pathBase}/v1/auth/oauth-callback"; + var uriBase = $"{request.Scheme}://{request.Host}{pathBase}".TrimEnd('/'); + var callBackRoute = "/api/oauth/open-id-auth"; // matches route in AuthController + return $"{uriBase}{callBackRoute}"; } private LexboxAuthSettings GetSettings() { - var baseUrl = _configuration["LexboxAuth:BaseUrl"] ?? "https://lexbox.org"; - var clientId = _configuration["LexboxAuth:ClientId"]; - if (string.IsNullOrWhiteSpace(clientId)) - { - throw new InvalidOperationException("LexboxAuth:ClientId must be configured."); - } - return new LexboxAuthSettings { - BaseUrl = baseUrl, - ClientId = clientId, - Prompt = _configuration["LexboxAuth:Prompt"] ?? "select_account", - Scope = _configuration["LexboxAuth:Scope"] ?? "profile openid offline_access sendandreceive", + ClientId = _configuration["LexboxAuth:ClientId"] + ?? throw new InvalidOperationException("LexboxAuth:ClientId must be configured."), + LoginBaseUrl = _configuration["LexboxAuth:LoginBaseUrl"] + ?? "https://lexbox.org/login", + OpenIdConfigUrl = _configuration["LexboxAuth:OpenIdConfigUrl"] + ?? "https://lexbox.org/.well-known/openid-configuration", + Prompt = _configuration["LexboxAuth:Prompt"] + ?? "select_account", + Scope = _configuration["LexboxAuth:Scope"] + ?? "profile openid offline_access sendandreceive", }; } @@ -186,8 +185,9 @@ private sealed record TokenResponse(string? AccessToken, string? IdToken, int? E private sealed class LexboxAuthSettings { - public string BaseUrl { get; set; } = ""; public string ClientId { get; set; } = ""; + public string LoginBaseUrl { get; set; } = ""; + public string OpenIdConfigUrl { get; set; } = ""; public string Prompt { get; set; } = ""; public string Scope { get; set; } = ""; } @@ -198,7 +198,7 @@ public void Dispose() GC.SuppressFinalize(this); } - private async Task GetOpenIdConfigurationAsync(HttpClient httpClient, string baseUrl) + private async Task GetOpenIdConfigurationAsync(HttpClient httpClient, string openIdConfigUrl) { if (_openIdConfiguration is not null && _openIdConfigExpiresAt > DateTimeOffset.UtcNow) { @@ -213,8 +213,7 @@ private async Task GetOpenIdConfigurationAsync(HttpClient h return _openIdConfiguration; } - var configUrl = $"{baseUrl.TrimEnd('/')}/.well-known/openid-configuration"; - using var response = await httpClient.GetAsync(configUrl); + using var response = await httpClient.GetAsync(openIdConfigUrl); response.EnsureSuccessStatusCode(); var payload = await response.Content.ReadAsStringAsync(); using var document = JsonDocument.Parse(payload); From ada7f552159dbd56c9f14f8edf3902d0c5584f10 Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Mon, 23 Feb 2026 15:45:10 -0500 Subject: [PATCH 09/38] Fix redirect uri --- Backend/Services/LexboxAuthService.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Backend/Services/LexboxAuthService.cs b/Backend/Services/LexboxAuthService.cs index 4f5ab39f10..18980a581b 100644 --- a/Backend/Services/LexboxAuthService.cs +++ b/Backend/Services/LexboxAuthService.cs @@ -114,7 +114,7 @@ private static string BuildRedirectUri(HttpRequest request) { var pathBase = request.PathBase.HasValue ? request.PathBase.Value : string.Empty; var uriBase = $"{request.Scheme}://{request.Host}{pathBase}".TrimEnd('/'); - var callBackRoute = "/api/oauth/open-id-auth"; // matches route in AuthController + var callBackRoute = "/v1/auth/oauth-callback"; // matches route in AuthController return $"{uriBase}{callBackRoute}"; } From 0c4fb8ac9d1bb6b24cc35638c1f26aeb4461bb99 Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Mon, 23 Feb 2026 15:45:52 -0500 Subject: [PATCH 10/38] Use client-id of lexbox pr 2180 --- Backend/appsettings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Backend/appsettings.json b/Backend/appsettings.json index 6a6db9c640..f3a24fdba4 100644 --- a/Backend/appsettings.json +++ b/Backend/appsettings.json @@ -11,7 +11,7 @@ }, "LexboxAuth": { "BaseUrl": "https://lexbox.org", - "ClientId": "becf2856-0690-434b-b192-a4032b72067f", + "ClientId": "the-combine", "Prompt": "select_account", "Scope": "profile openid offline_access sendandreceive" }, From ba6b9c79aa0cad6546c973e9c73c1e4139182f49 Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Mon, 23 Feb 2026 17:42:28 -0500 Subject: [PATCH 11/38] Refactor attempt --- .../Controllers/AuthControllerTests.cs | 124 ++++--- .../Mocks/AuthenticationServiceMock.cs | 25 ++ Backend/BackendFramework.csproj | 7 +- Backend/Controllers/AuthController.cs | 110 +++--- Backend/Interfaces/ILexboxAuthService.cs | 13 - Backend/Models/AuthStatus.cs | 19 +- Backend/Models/LexboxAuthResult.cs | 8 - Backend/Services/LexboxAuthService.cs | 340 ------------------ Backend/Startup.cs | 52 ++- src/api/api/auth-api.ts | 74 ++-- src/api/models/auth-status.ts | 2 +- src/backend/index.ts | 8 +- src/components/Lexbox/LexboxLogin.tsx | 2 +- 13 files changed, 246 insertions(+), 538 deletions(-) create mode 100644 Backend.Tests/Mocks/AuthenticationServiceMock.cs delete mode 100644 Backend/Interfaces/ILexboxAuthService.cs delete mode 100644 Backend/Models/LexboxAuthResult.cs delete mode 100644 Backend/Services/LexboxAuthService.cs diff --git a/Backend.Tests/Controllers/AuthControllerTests.cs b/Backend.Tests/Controllers/AuthControllerTests.cs index cf35478959..37eced6829 100644 --- a/Backend.Tests/Controllers/AuthControllerTests.cs +++ b/Backend.Tests/Controllers/AuthControllerTests.cs @@ -1,23 +1,26 @@ using System; using System.Collections.Generic; +using System.Security.Claims; using System.Threading.Tasks; using Backend.Tests.Mocks; using BackendFramework.Controllers; -using BackendFramework.Interfaces; 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 LexboxAuthServiceMock _lexboxAuthService = null!; private PermissionServiceMock _permissionService = null!; private AuthController _controller = null!; + private const string UserId = "AuthControllerTestsUserId"; + public void Dispose() { _controller?.Dispose(); @@ -27,76 +30,99 @@ public void Dispose() [SetUp] public void Setup() { - _lexboxAuthService = new LexboxAuthServiceMock(); - _permissionService = new PermissionServiceMock(); var configValues = new Dictionary { { "LexboxAuth:PostLoginRedirect", "/" } }; var configuration = new ConfigurationBuilder().AddInMemoryCollection(configValues).Build(); - _controller = new AuthController(_lexboxAuthService, _permissionService, configuration); + _permissionService = new PermissionServiceMock(); + _controller = new AuthController(configuration, _permissionService); } [Test] - public void GetAuthStatusUnauthorizedReturnsForbid() + public async Task GetAuthStatusUnauthorizedReturnsForbid() { _controller.ControllerContext.HttpContext = PermissionServiceMock.UnauthorizedHttpContext(); - var result = _controller.GetAuthStatus(); + var result = await _controller.GetAuthStatus(); Assert.That(result, Is.InstanceOf()); } [Test] - public void GetAuthStatusReturnsLexboxUserWhenLoggedIn() + public async Task GetAuthStatusReturnsLexboxUserWhenLoggedIn() + { + var claims = new List { 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() { - var context = PermissionServiceMock.HttpContextWithUserId("user-1"); - context.Request.Headers["Cookie"] = "lexbox_session_id=session-1"; - _controller.ControllerContext.HttpContext = context; - _lexboxAuthService.LoggedInUser = new LexboxAuthUser { UserId = "lex-1", DisplayName = "Lex User" }; - - var result = _controller.GetAuthStatus(); - - Assert.That(result, Is.InstanceOf()); - var payload = ((OkObjectResult)result).Value as AuthStatus; - Assert.That(payload, Is.Not.Null); - Assert.That(payload!.LoggedIn, Is.True); - Assert.That(payload.LoggedInAs, Is.EqualTo("Lex User")); - Assert.That(payload.UserId, Is.EqualTo("lex-1")); + _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 void CompleteLexboxLoginRedirectsWhenReturnUrlPresent() + public void GetLexboxLoginUrlReturnsExpectedLoginPath() { - _controller.ControllerContext.HttpContext = PermissionServiceMock.HttpContextWithUserId("user-1"); - _lexboxAuthService.CompleteResult = new LexboxAuthResult - { - User = new LexboxAuthUser { UserId = "lex-1", DisplayName = "Lex User" }, - ReturnUrl = "/after-login", - }; + _controller.ControllerContext.HttpContext = PermissionServiceMock.HttpContextWithUserId(UserId); - var result = _controller.CompleteLexboxLogin("code", "state").Result; + var result = _controller.GetLexboxLoginUrl() as OkObjectResult; - Assert.That(result, Is.InstanceOf()); - Assert.That(((LocalRedirectResult)result).Url, Is.EqualTo("/after-login")); + Assert.That(result, Is.Not.Null); + var loginUrl = result.Value as LexboxLoginUrl; + Assert.That(loginUrl, Is.Not.Null); + Assert.That(loginUrl.Url, Is.EqualTo("/v1/auth/lexbox-login?returnUrl=%2F")); + } + + [Test] + public void StartLexboxLoginReturnsChallengeWithConfiguredSchemeAndRedirect() + { + _controller.ControllerContext.HttpContext = PermissionServiceMock.HttpContextWithUserId(UserId); + + var challengeResult = _controller.StartLexboxLogin("/after-login") as ChallengeResult; + + Assert.That(challengeResult, Is.Not.Null); + Assert.That(challengeResult.AuthenticationSchemes, Has.Count.EqualTo(1)); + Assert.That(challengeResult.AuthenticationSchemes[0], Is.EqualTo("LexboxOidc")); + Assert.That(challengeResult.Properties, Is.Not.Null); + Assert.That(challengeResult.Properties.RedirectUri, Is.EqualTo("/after-login")); + } + + [Test] + public void StartLexboxLoginUnauthorizedReturnsForbid() + { + _controller.ControllerContext.HttpContext = PermissionServiceMock.UnauthorizedHttpContext(); + + var result = _controller.StartLexboxLogin("/after-login"); + + Assert.That(result, Is.InstanceOf()); } - private sealed class LexboxAuthServiceMock : ILexboxAuthService + private static HttpContext GetAuthContext(AuthenticateResult authenticateResult) { - public LexboxAuthUser? LoggedInUser { get; set; } - public LexboxAuthResult? CompleteResult { get; set; } - - public LexboxLoginUrl CreateLoginUrl(HttpRequest request, string sessionId, string? returnUrl) - { - return new LexboxLoginUrl { Url = "not-a-valid-url" }; - } - - public Task CompleteLoginAsync(HttpRequest request, string code, string state) - { - return Task.FromResult(CompleteResult); - } - - public LexboxAuthUser? GetLoggedInUser(string? sessionId) - { - return LoggedInUser; - } + var context = PermissionServiceMock.HttpContextWithUserId(UserId); + var services = new ServiceCollection(); + services.AddSingleton(new AuthenticationServiceMock(authenticateResult)); + context.RequestServices = services.BuildServiceProvider(); + return context; } } } diff --git a/Backend.Tests/Mocks/AuthenticationServiceMock.cs b/Backend.Tests/Mocks/AuthenticationServiceMock.cs new file mode 100644 index 0000000000..10754aeb71 --- /dev/null +++ b/Backend.Tests/Mocks/AuthenticationServiceMock.cs @@ -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 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; + } +} diff --git a/Backend/BackendFramework.csproj b/Backend/BackendFramework.csproj index 5c8479cefc..e7b4fc66da 100644 --- a/Backend/BackendFramework.csproj +++ b/Backend/BackendFramework.csproj @@ -18,9 +18,10 @@ NU1701 - - - + + + + diff --git a/Backend/Controllers/AuthController.cs b/Backend/Controllers/AuthController.cs index 8ed711b7df..76ca591430 100644 --- a/Backend/Controllers/AuthController.cs +++ b/Backend/Controllers/AuthController.cs @@ -4,108 +4,88 @@ using BackendFramework.Interfaces; using BackendFramework.Models; using BackendFramework.Otel; +using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.WebUtilities; using Microsoft.Extensions.Configuration; namespace BackendFramework.Controllers { [Produces("application/json")] [Route("v1/auth")] - public class AuthController(ILexboxAuthService lexboxAuthService, IPermissionService permissionService, - IConfiguration configuration) : Controller + public class AuthController(IConfiguration configuration, IPermissionService permissionService) : Controller { - private readonly IPermissionService _permissionService = permissionService; - private readonly ILexboxAuthService _lexboxAuthService = lexboxAuthService; private readonly IConfiguration _configuration = configuration; + private readonly IPermissionService _permissionService = permissionService; private const string otelTagName = "otel.AuthController"; - private const string LexboxSessionCookieName = "lexbox_session_id"; + private const string LexboxCookieScheme = "LexboxCookie"; + private const string LexboxOidcScheme = "LexboxOidc"; private const string PostLoginRedirectConfigKey = "LexboxAuth:PostLoginRedirect"; /// Gets authentication status for the current request. [HttpGet("status", Name = "GetAuthStatus")] [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(AuthStatus))] [ProducesResponseType(StatusCodes.Status403Forbidden)] - public IActionResult GetAuthStatus() + public async Task GetAuthStatus() { using var activity = OtelService.StartActivityWithTag(otelTagName, "getting auth status"); + if (!_permissionService.IsCurrentUserAuthenticated(HttpContext)) { return Forbid(); } - var sessionId = Request.Cookies[LexboxSessionCookieName]; - var user = _lexboxAuthService.GetLoggedInUser(sessionId); - return user is null ? Ok(AuthStatus.LoggedOut()) : Ok(AuthStatus.LoggedInLexboxUser(user)); + 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)); } /// Generates a Lexbox login URL for OIDC sign-in. [HttpGet("lexbox-login-url", Name = "GetLexboxLoginUrl")] [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(LexboxLoginUrl))] [ProducesResponseType(StatusCodes.Status403Forbidden)] - [ProducesResponseType(StatusCodes.Status500InternalServerError)] public IActionResult GetLexboxLoginUrl() { using var activity = OtelService.StartActivityWithTag(otelTagName, "getting lexbox login url"); + if (!_permissionService.IsCurrentUserAuthenticated(HttpContext)) { return Forbid(); } - try - { - var sessionId = Guid.NewGuid().ToString("N"); - Response.Cookies.Append(LexboxSessionCookieName, sessionId, new CookieOptions - { - HttpOnly = true, - IsEssential = true, - Path = "/", - SameSite = SameSiteMode.Lax, - Secure = Request.IsHttps, - // Todo: Add MaxAge or Expires to use this cookie across settings. - // As of now, the cookie is generated but not actually used. - }); - var normalizedReturnUrl = NormalizeReturnUrl(_configuration[PostLoginRedirectConfigKey]) - ?? NormalizeReturnUrl(Domain.FrontendDomain); - var result = _lexboxAuthService.CreateLoginUrl(Request, sessionId, normalizedReturnUrl); - return Ok(result); - } - catch (InvalidOperationException ex) - { - return Problem(ex.Message, statusCode: StatusCodes.Status500InternalServerError); - } + var returnUrl = NormalizeReturnUrl(_configuration[PostLoginRedirectConfigKey]) + ?? NormalizeReturnUrl(Domain.FrontendDomain) + ?? "/"; + var loginUrl = QueryHelpers.AddQueryString("/v1/auth/lexbox-login", "returnUrl", returnUrl); + return Ok(new LexboxLoginUrl { Url = loginUrl }); } - /// Completes the Lexbox OAuth login and stores the login status. - [HttpGet("oauth-callback", Name = "LexboxOauthCallback")] - [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(AuthStatus))] - [ProducesResponseType(StatusCodes.Status400BadRequest)] + /// Starts Lexbox OpenID Connect login challenge. + [HttpGet("lexbox-login", Name = "StartLexboxLogin")] + [ProducesResponseType(StatusCodes.Status302Found)] [ProducesResponseType(StatusCodes.Status403Forbidden)] - public async Task CompleteLexboxLogin([FromQuery] string? code, [FromQuery] string? state) + public IActionResult StartLexboxLogin([FromQuery] string? returnUrl) { - using var activity = OtelService.StartActivityWithTag(otelTagName, "completing lexbox login"); + using var activity = OtelService.StartActivityWithTag(otelTagName, "starting lexbox login"); + if (!_permissionService.IsCurrentUserAuthenticated(HttpContext)) { return Forbid(); } - if (string.IsNullOrWhiteSpace(code) || string.IsNullOrWhiteSpace(state)) - { - return BadRequest("Missing code or state."); - } - - var result = await _lexboxAuthService.CompleteLoginAsync(Request, code, state); - if (result?.User is null) - { - return Ok(AuthStatus.LoggedOut()); - } - - var redirectUrl = NormalizeReturnUrl(result.ReturnUrl) + var redirectUrl = NormalizeReturnUrl(returnUrl) ?? NormalizeReturnUrl(_configuration[PostLoginRedirectConfigKey]); - return redirectUrl is null - ? Ok(AuthStatus.LoggedInLexboxUser(result.User)) - : LocalRedirect(redirectUrl); + Console.WriteLine($"Redirect URL for OIDC login: {redirectUrl}"); + var authProperties = new AuthenticationProperties { RedirectUri = redirectUrl ?? "/" }; + + return Challenge(authProperties, LexboxOidcScheme); } private static string? NormalizeReturnUrl(string? url) @@ -118,5 +98,29 @@ public async Task CompleteLexboxLogin([FromQuery] string? code, [ 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 }; + } } } diff --git a/Backend/Interfaces/ILexboxAuthService.cs b/Backend/Interfaces/ILexboxAuthService.cs deleted file mode 100644 index 9e81e5621e..0000000000 --- a/Backend/Interfaces/ILexboxAuthService.cs +++ /dev/null @@ -1,13 +0,0 @@ -using BackendFramework.Models; -using Microsoft.AspNetCore.Http; -using System.Threading.Tasks; - -namespace BackendFramework.Interfaces -{ - public interface ILexboxAuthService - { - LexboxLoginUrl CreateLoginUrl(HttpRequest request, string sessionId, string? returnUrl); - Task CompleteLoginAsync(HttpRequest request, string code, string state); - LexboxAuthUser? GetLoggedInUser(string? sessionId); - } -} diff --git a/Backend/Models/AuthStatus.cs b/Backend/Models/AuthStatus.cs index fb8e7917b9..6a148cbfda 100644 --- a/Backend/Models/AuthStatus.cs +++ b/Backend/Models/AuthStatus.cs @@ -2,25 +2,20 @@ namespace BackendFramework.Models { public class AuthStatus { - public bool LoggedIn { get; set; } + public bool IsLoggedIn { get; set; } public string? LoggedInAs { get; set; } public string? UserId { get; set; } public static AuthStatus LoggedOut() => new() { - LoggedIn = false, - LoggedInAs = null, - UserId = null, + IsLoggedIn = false }; - public static AuthStatus LoggedInLexboxUser(LexboxAuthUser user) + public static AuthStatus LoggedIn(LexboxAuthUser user) => new() { - return new AuthStatus - { - LoggedIn = true, - LoggedInAs = user.DisplayName, - UserId = user.UserId, - }; - } + IsLoggedIn = true, + LoggedInAs = user.DisplayName, + UserId = user.UserId + }; } } diff --git a/Backend/Models/LexboxAuthResult.cs b/Backend/Models/LexboxAuthResult.cs deleted file mode 100644 index a9a61dbec2..0000000000 --- a/Backend/Models/LexboxAuthResult.cs +++ /dev/null @@ -1,8 +0,0 @@ -namespace BackendFramework.Models -{ - public class LexboxAuthResult - { - public LexboxAuthUser? User { get; init; } - public string? ReturnUrl { get; init; } - } -} diff --git a/Backend/Services/LexboxAuthService.cs b/Backend/Services/LexboxAuthService.cs deleted file mode 100644 index 18980a581b..0000000000 --- a/Backend/Services/LexboxAuthService.cs +++ /dev/null @@ -1,340 +0,0 @@ -using System; -using System.Collections.Concurrent; -using System.Collections.Generic; -using System.IdentityModel.Tokens.Jwt; -using System.Linq; -using System.Net.Http; -using System.Net.Http.Headers; -using System.Security.Cryptography; -using System.Text.Json; -using System.Threading; -using System.Threading.Tasks; -using BackendFramework.Interfaces; -using BackendFramework.Models; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.WebUtilities; -using Microsoft.Extensions.Configuration; - -namespace BackendFramework.Services -{ - public class LexboxAuthService(IConfiguration configuration, IHttpClientFactory httpClientFactory) - : ILexboxAuthService, IDisposable - { - private readonly IConfiguration _configuration = configuration; - private readonly IHttpClientFactory _httpClientFactory = httpClientFactory; - private readonly ConcurrentDictionary _stateStore = new(); - private readonly ConcurrentDictionary _sessionStore = new(); - private readonly SemaphoreSlim _openIdConfigLock = new(1, 1); - private OpenIdConfiguration? _openIdConfiguration; - private DateTimeOffset _openIdConfigExpiresAt = DateTimeOffset.MinValue; - - private static readonly TimeSpan StateTtl = TimeSpan.FromMinutes(15); - private static readonly TimeSpan OpenIdConfigTtl = TimeSpan.FromHours(1); - private static readonly TimeSpan DefaultTokenLifetime = TimeSpan.FromHours(1); - - public LexboxLoginUrl CreateLoginUrl(HttpRequest request, string sessionId, string? returnUrl) - { - var settings = GetSettings(); - var state = CreateState(); - var codeVerifier = CreateCodeVerifier(); - var codeChallenge = CreateCodeChallenge(codeVerifier); - - CleanupExpiredStates(); - _stateStore[state] = new LexboxAuthState(codeVerifier, DateTimeOffset.UtcNow, sessionId, returnUrl); - - var query = new Dictionary - { - ["code_challenge"] = codeChallenge, - ["code_challenge_method"] = "S256", - ["client_id"] = settings.ClientId, - ["prompt"] = settings.Prompt, - ["redirect_uri"] = BuildRedirectUri(request), - ["response_type"] = "code", - ["scope"] = settings.Scope, - ["state"] = state, - }; - - var loginReturnUrl = QueryHelpers.AddQueryString("/api/oauth/open-id-auth", query); - Console.WriteLine($"Return URL: {loginReturnUrl}"); // TODO: Remove or replace with proper logging - var loginUrl = QueryHelpers.AddQueryString(settings.LoginBaseUrl, "ReturnUrl", loginReturnUrl); - Console.WriteLine($"Login URL: {loginUrl}"); // TODO: Remove or replace with proper logging - return new LexboxLoginUrl { Url = loginUrl }; - } - - public LexboxAuthUser? GetLoggedInUser(string? sessionId) - { - if (string.IsNullOrWhiteSpace(sessionId) || !_sessionStore.TryGetValue(sessionId, out var session)) - { - return null; - } - - if (session.ExpiresAt <= DateTimeOffset.UtcNow) - { - _sessionStore.TryRemove(sessionId, out _); - return null; - } - - return session.User; - } - - public async Task CompleteLoginAsync(HttpRequest request, string code, string state) - { - CleanupExpiredStates(); - if (!_stateStore.TryRemove(state, out var pending)) - { - return null; - } - - var settings = GetSettings(); - var redirectUri = BuildRedirectUri(request); - var httpClient = _httpClientFactory.CreateClient(); - var openIdConfig = await GetOpenIdConfigurationAsync(httpClient, settings.OpenIdConfigUrl); - var tokenResponse = await ExchangeCodeForTokenAsync( - httpClient, openIdConfig.TokenEndpoint, settings.ClientId, code, redirectUri, pending.CodeVerifier); - if (tokenResponse is null) - { - return new LexboxAuthResult { User = null, ReturnUrl = null }; - } - - var user = GetUserFromIdToken(tokenResponse.IdToken) - ?? await GetUserFromUserInfoAsync( - httpClient, openIdConfig.UserInfoEndpoint, tokenResponse.AccessToken); - if (user is null) - { - return new LexboxAuthResult { User = null, ReturnUrl = null }; - } - - var expiresInSeconds = tokenResponse.ExpiresIn ?? (int)DefaultTokenLifetime.TotalSeconds; - var expiresAt = DateTimeOffset.UtcNow.AddSeconds(expiresInSeconds); - _sessionStore[pending.SessionId] = new LexboxAuthSession(user, expiresAt, tokenResponse.AccessToken); - return new LexboxAuthResult { User = user, ReturnUrl = pending.ReturnUrl }; - } - - private static string BuildRedirectUri(HttpRequest request) - { - var pathBase = request.PathBase.HasValue ? request.PathBase.Value : string.Empty; - var uriBase = $"{request.Scheme}://{request.Host}{pathBase}".TrimEnd('/'); - var callBackRoute = "/v1/auth/oauth-callback"; // matches route in AuthController - return $"{uriBase}{callBackRoute}"; - } - - private LexboxAuthSettings GetSettings() - { - return new LexboxAuthSettings - { - ClientId = _configuration["LexboxAuth:ClientId"] - ?? throw new InvalidOperationException("LexboxAuth:ClientId must be configured."), - LoginBaseUrl = _configuration["LexboxAuth:LoginBaseUrl"] - ?? "https://lexbox.org/login", - OpenIdConfigUrl = _configuration["LexboxAuth:OpenIdConfigUrl"] - ?? "https://lexbox.org/.well-known/openid-configuration", - Prompt = _configuration["LexboxAuth:Prompt"] - ?? "select_account", - Scope = _configuration["LexboxAuth:Scope"] - ?? "profile openid offline_access sendandreceive", - }; - } - - private static string CreateCodeVerifier() - { - Span bytes = stackalloc byte[32]; - RandomNumberGenerator.Fill(bytes); - return Base64UrlEncode(bytes); - } - - private static string CreateCodeChallenge(string verifier) - { - var bytes = System.Text.Encoding.ASCII.GetBytes(verifier); - var hash = SHA256.HashData(bytes); - return Base64UrlEncode(hash); - } - - private static string CreateState() - { - Span bytes = stackalloc byte[16]; - RandomNumberGenerator.Fill(bytes); - return $"{Guid.NewGuid()}-{Base64UrlEncode(bytes)}"; - } - - private void CleanupExpiredStates() - { - var cutoff = DateTimeOffset.UtcNow - StateTtl; - foreach (var entry in _stateStore) - { - if (entry.Value.CreatedAt < cutoff) - { - _stateStore.TryRemove(entry.Key, out _); - } - } - } - - private static string Base64UrlEncode(ReadOnlySpan data) - { - var base64 = Convert.ToBase64String(data); - return base64.TrimEnd('=').Replace('+', '-').Replace('/', '_'); - } - - private sealed record LexboxAuthState( - string CodeVerifier, DateTimeOffset CreatedAt, string SessionId, string? ReturnUrl); - - private sealed record LexboxAuthSession(LexboxAuthUser User, DateTimeOffset ExpiresAt, string? AccessToken); - - private sealed record OpenIdConfiguration(string TokenEndpoint, string? UserInfoEndpoint); - - private sealed record TokenResponse(string? AccessToken, string? IdToken, int? ExpiresIn); - - private sealed class LexboxAuthSettings - { - public string ClientId { get; set; } = ""; - public string LoginBaseUrl { get; set; } = ""; - public string OpenIdConfigUrl { get; set; } = ""; - public string Prompt { get; set; } = ""; - public string Scope { get; set; } = ""; - } - - public void Dispose() - { - _openIdConfigLock.Dispose(); - GC.SuppressFinalize(this); - } - - private async Task GetOpenIdConfigurationAsync(HttpClient httpClient, string openIdConfigUrl) - { - if (_openIdConfiguration is not null && _openIdConfigExpiresAt > DateTimeOffset.UtcNow) - { - return _openIdConfiguration; - } - - await _openIdConfigLock.WaitAsync(); - try - { - if (_openIdConfiguration is not null && _openIdConfigExpiresAt > DateTimeOffset.UtcNow) - { - return _openIdConfiguration; - } - - using var response = await httpClient.GetAsync(openIdConfigUrl); - response.EnsureSuccessStatusCode(); - var payload = await response.Content.ReadAsStringAsync(); - using var document = JsonDocument.Parse(payload); - var root = document.RootElement; - var tokenEndpoint = root.GetProperty("token_endpoint").GetString() - ?? throw new InvalidOperationException("Token endpoint missing from discovery document."); - var userInfoEndpoint = root.TryGetProperty("userinfo_endpoint", out var userInfoProperty) - ? userInfoProperty.GetString() - : null; - _openIdConfiguration = new OpenIdConfiguration(tokenEndpoint, userInfoEndpoint); - _openIdConfigExpiresAt = DateTimeOffset.UtcNow.Add(OpenIdConfigTtl); - return _openIdConfiguration; - } - finally - { - _openIdConfigLock.Release(); - } - } - - private static async Task ExchangeCodeForTokenAsync(HttpClient httpClient, - string tokenEndpoint, string clientId, string code, string redirectUri, string codeVerifier) - { - using var request = new HttpRequestMessage(HttpMethod.Post, tokenEndpoint) - { - Content = new FormUrlEncodedContent(new Dictionary - { - ["client_id"] = clientId, - ["code"] = code, - ["code_verifier"] = codeVerifier, - ["grant_type"] = "authorization_code", - ["redirect_uri"] = redirectUri, - }) - }; - - using var response = await httpClient.SendAsync(request); - if (!response.IsSuccessStatusCode) - { - return null; - } - - var payload = await response.Content.ReadAsStringAsync(); - using var document = JsonDocument.Parse(payload); - var root = document.RootElement; - var accessToken = root.TryGetProperty("access_token", out var accessTokenProperty) - ? accessTokenProperty.GetString() - : null; - var idToken = root.TryGetProperty("id_token", out var idTokenProperty) - ? idTokenProperty.GetString() - : null; - int? expiresIn = root.TryGetProperty("expires_in", out var expiresProperty) - ? expiresProperty.GetInt32() - : null; - return new TokenResponse(accessToken, idToken, expiresIn); - } - - private static LexboxAuthUser? GetUserFromIdToken(string? idToken) - { - if (string.IsNullOrWhiteSpace(idToken)) - { - return null; - } - - try - { - var handler = new JwtSecurityTokenHandler(); - var token = handler.ReadJwtToken(idToken); - var userId = GetClaimValue(token, "sub")?.Trim(); - var displayName = GetClaimValue(token, "preferred_username") - ?? GetClaimValue(token, "email") - ?? GetClaimValue(token, "name") - ?? GetClaimValue(token, "upn") - ?? userId; - displayName = displayName?.Trim(); - - return string.IsNullOrEmpty(displayName) && string.IsNullOrEmpty(userId) - ? null - : new LexboxAuthUser { UserId = userId, DisplayName = displayName }; - } - catch (Exception) - { - return null; - } - } - - private static string? GetClaimValue(JwtSecurityToken token, string claimType) - { - return token.Claims.FirstOrDefault( - claim => string.Equals(claim.Type, claimType, StringComparison.OrdinalIgnoreCase))?.Value; - } - - private static async Task GetUserFromUserInfoAsync( - HttpClient httpClient, string? userInfoEndpoint, string? accessToken) - { - if (string.IsNullOrWhiteSpace(userInfoEndpoint) || string.IsNullOrWhiteSpace(accessToken)) - { - return null; - } - - using var request = new HttpRequestMessage(HttpMethod.Get, userInfoEndpoint); - request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken); - using var response = await httpClient.SendAsync(request); - if (!response.IsSuccessStatusCode) - { - return null; - } - - var payload = await response.Content.ReadAsStringAsync(); - using var document = JsonDocument.Parse(payload); - var root = document.RootElement; - var userId = root.TryGetProperty("sub", out var subProperty) ? subProperty.GetString()?.Trim() : null; - var displayName = root.TryGetProperty("preferred_username", out var preferredProperty) - ? preferredProperty.GetString() - : null; - displayName ??= root.TryGetProperty("email", out var emailProperty) ? emailProperty.GetString() : null; - displayName ??= root.TryGetProperty("name", out var nameProperty) ? nameProperty.GetString() : null; - displayName ??= userId; - displayName = displayName?.Trim(); - - return (string.IsNullOrEmpty(displayName) && string.IsNullOrEmpty(userId)) - ? null - : new LexboxAuthUser { UserId = userId, DisplayName = displayName }; - } - } -} diff --git a/Backend/Startup.cs b/Backend/Startup.cs index ed12b58ba2..a97daf2e74 100644 --- a/Backend/Startup.cs +++ b/Backend/Startup.cs @@ -133,6 +133,16 @@ public void ConfigureServices(IServiceCollection services) } var key = ASCII.GetBytes(secretKey); + + var lexboxBaseUrl = (Configuration["LexboxAuth:BaseUrl"] ?? "https://lexbox.org").TrimEnd('/'); + var lexboxAuthority = Configuration["LexboxAuth:Authority"] ?? lexboxBaseUrl; + var lexboxMetadataAddress = Configuration["LexboxAuth:OpenIdConfigUrl"] + ?? $"{lexboxBaseUrl}/.well-known/openid-configuration"; + var lexboxClientId = Configuration["LexboxAuth:ClientId"] ?? "the-combine"; + var lexboxPrompt = Configuration["LexboxAuth:Prompt"] ?? "select_account"; + var lexboxScope = Configuration["LexboxAuth:Scope"] ?? "profile openid offline_access sendandreceive"; + var lexboxCallbackPath = Configuration["LexboxAuth:CallbackPath"] ?? "/v1/auth/oauth-callback"; + services.AddAuthentication(x => { x.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme; @@ -144,10 +154,45 @@ 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 => + { + context.ProtocolMessage.Prompt = lexboxPrompt; + return System.Threading.Tasks.Task.CompletedTask; }; }); @@ -255,9 +300,6 @@ public void ConfigureServices(IServiceCollection services) services.AddTransient(); services.AddTransient(); - // Lexbox Auth - services.AddSingleton(); - // Lift Service - Singleton to avoid initializing the Sldr multiple times, // also to avoid leaking LanguageTag data services.AddSingleton(); diff --git a/src/api/api/auth-api.ts b/src/api/api/auth-api.ts index 0ab0745598..9740d4310f 100644 --- a/src/api/api/auth-api.ts +++ b/src/api/api/auth-api.ts @@ -122,17 +122,15 @@ export const AuthApiAxiosParamCreator = function ( }, /** * - * @param {string} [code] - * @param {string} [state] + * @param {string} [returnUrl] * @param {*} [options] Override http request option. * @throws {RequiredError} */ - lexboxOauthCallback: async ( - code?: string, - state?: string, + startLexboxLogin: async ( + returnUrl?: string, options: any = {} ): Promise => { - const localVarPath = `/v1/auth/oauth-callback`; + const localVarPath = `/v1/auth/lexbox-login`; // use dummy base URL string because the URL constructor only accepts absolute URLs. const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); let baseOptions; @@ -148,12 +146,8 @@ export const AuthApiAxiosParamCreator = function ( const localVarHeaderParameter = {} as any; const localVarQueryParameter = {} as any; - if (code !== undefined) { - localVarQueryParameter["code"] = code; - } - - if (state !== undefined) { - localVarQueryParameter["state"] = state; + if (returnUrl !== undefined) { + localVarQueryParameter["returnUrl"] = returnUrl; } setSearchParams(localVarUrlObj, localVarQueryParameter, options.query); @@ -220,24 +214,18 @@ export const AuthApiFp = function (configuration?: Configuration) { }, /** * - * @param {string} [code] - * @param {string} [state] + * @param {string} [returnUrl] * @param {*} [options] Override http request option. * @throws {RequiredError} */ - async lexboxOauthCallback( - code?: string, - state?: string, + async startLexboxLogin( + returnUrl?: string, options?: any ): Promise< - (axios?: AxiosInstance, basePath?: string) => AxiosPromise + (axios?: AxiosInstance, basePath?: string) => AxiosPromise > { const localVarAxiosArgs = - await localVarAxiosParamCreator.lexboxOauthCallback( - code, - state, - options - ); + await localVarAxiosParamCreator.startLexboxLogin(returnUrl, options); return createRequestFunction( localVarAxiosArgs, globalAxios, @@ -281,42 +269,30 @@ export const AuthApiFactory = function ( }, /** * - * @param {string} [code] - * @param {string} [state] + * @param {string} [returnUrl] * @param {*} [options] Override http request option. * @throws {RequiredError} */ - lexboxOauthCallback( - code?: string, - state?: string, - options?: any - ): AxiosPromise { + startLexboxLogin(returnUrl?: string, options?: any): AxiosPromise { return localVarFp - .lexboxOauthCallback(code, state, options) + .startLexboxLogin(returnUrl, options) .then((request) => request(axios, basePath)); }, }; }; /** - * Request parameters for lexboxOauthCallback operation in AuthApi. + * Request parameters for startLexboxLogin operation in AuthApi. * @export - * @interface AuthApiLexboxOauthCallbackRequest + * @interface AuthApiStartLexboxLoginRequest */ -export interface AuthApiLexboxOauthCallbackRequest { - /** - * - * @type {string} - * @memberof AuthApiLexboxOauthCallback - */ - readonly code?: string; - +export interface AuthApiStartLexboxLoginRequest { /** * * @type {string} - * @memberof AuthApiLexboxOauthCallback + * @memberof AuthApiStartLexboxLogin */ - readonly state?: string; + readonly returnUrl?: string; } /** @@ -352,21 +328,17 @@ export class AuthApi extends BaseAPI { /** * - * @param {AuthApiLexboxOauthCallbackRequest} requestParameters Request parameters. + * @param {AuthApiStartLexboxLoginRequest} requestParameters Request parameters. * @param {*} [options] Override http request option. * @throws {RequiredError} * @memberof AuthApi */ - public lexboxOauthCallback( - requestParameters: AuthApiLexboxOauthCallbackRequest = {}, + public startLexboxLogin( + requestParameters: AuthApiStartLexboxLoginRequest = {}, options?: any ) { return AuthApiFp(this.configuration) - .lexboxOauthCallback( - requestParameters.code, - requestParameters.state, - options - ) + .startLexboxLogin(requestParameters.returnUrl, options) .then((request) => request(this.axios, this.basePath)); } } diff --git a/src/api/models/auth-status.ts b/src/api/models/auth-status.ts index 32741353ec..5b29f4ad6f 100644 --- a/src/api/models/auth-status.ts +++ b/src/api/models/auth-status.ts @@ -23,7 +23,7 @@ export interface AuthStatus { * @type {boolean} * @memberof AuthStatus */ - loggedIn?: boolean; + isLoggedIn?: boolean; /** * * @type {string} diff --git a/src/backend/index.ts b/src/backend/index.ts index 97cdc06eb3..56b3722c44 100644 --- a/src/backend/index.ts +++ b/src/backend/index.ts @@ -192,8 +192,12 @@ export async function getExternalLoginUrl(): Promise { return response.data.url ?? ""; } -export function logoutCurrentUser(): void { - LocalStorage.clearLocalStorage(); +export async function logoutCurrentUser(): Promise { + return; +} + +export async function startLexboxLogin(): Promise { + await authApi.startLexboxLogin(undefined, defaultOptions()); } /* AvatarController.cs */ diff --git a/src/components/Lexbox/LexboxLogin.tsx b/src/components/Lexbox/LexboxLogin.tsx index 0d92c066e6..485205c5b1 100644 --- a/src/components/Lexbox/LexboxLogin.tsx +++ b/src/components/Lexbox/LexboxLogin.tsx @@ -71,7 +71,7 @@ export default function LexboxLogin(props: LexboxLoginProps): ReactElement { } }; - const isLoggedIn = status?.loggedIn ?? false; + const isLoggedIn = status?.isLoggedIn ?? false; const menuOpen = Boolean(menuAnchor); const label = status?.loggedInAs ?? t("login.login"); From 82c75b029f06191ce07634ba3581e3700203f82f Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Mon, 23 Feb 2026 18:14:52 -0500 Subject: [PATCH 12/38] Attempted url fix --- .../Controllers/AuthControllerTests.cs | 11 ++++---- Backend/Controllers/AuthController.cs | 16 ++++------- Backend/Startup.cs | 7 +++-- Backend/appsettings.json | 3 ++- src/api/.openapi-generator/FILES | 1 - src/api/api/auth-api.ts | 6 ++--- src/api/models/index.ts | 1 - src/api/models/lexbox-login-url.ts | 27 ------------------- src/backend/index.ts | 3 +-- 9 files changed, 19 insertions(+), 56 deletions(-) delete mode 100644 src/api/models/lexbox-login-url.ts diff --git a/Backend.Tests/Controllers/AuthControllerTests.cs b/Backend.Tests/Controllers/AuthControllerTests.cs index 37eced6829..737beb5aeb 100644 --- a/Backend.Tests/Controllers/AuthControllerTests.cs +++ b/Backend.Tests/Controllers/AuthControllerTests.cs @@ -84,12 +84,13 @@ public void GetLexboxLoginUrlReturnsExpectedLoginPath() { _controller.ControllerContext.HttpContext = PermissionServiceMock.HttpContextWithUserId(UserId); - var result = _controller.GetLexboxLoginUrl() as OkObjectResult; + var challengeResult = _controller.GetLexboxLoginUrl() as ChallengeResult; - Assert.That(result, Is.Not.Null); - var loginUrl = result.Value as LexboxLoginUrl; - Assert.That(loginUrl, Is.Not.Null); - Assert.That(loginUrl.Url, Is.EqualTo("/v1/auth/lexbox-login?returnUrl=%2F")); + Assert.That(challengeResult, Is.Not.Null); + Assert.That(challengeResult.AuthenticationSchemes, Has.Count.EqualTo(1)); + Assert.That(challengeResult.AuthenticationSchemes[0], Is.EqualTo("LexboxOidc")); + Assert.That(challengeResult.Properties, Is.Not.Null); + Assert.That(challengeResult.Properties.RedirectUri, Is.EqualTo("/")); } [Test] diff --git a/Backend/Controllers/AuthController.cs b/Backend/Controllers/AuthController.cs index 76ca591430..14bcc4ce6c 100644 --- a/Backend/Controllers/AuthController.cs +++ b/Backend/Controllers/AuthController.cs @@ -7,7 +7,6 @@ using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; -using Microsoft.AspNetCore.WebUtilities; using Microsoft.Extensions.Configuration; namespace BackendFramework.Controllers @@ -49,22 +48,17 @@ public async Task GetAuthStatus() /// Generates a Lexbox login URL for OIDC sign-in. [HttpGet("lexbox-login-url", Name = "GetLexboxLoginUrl")] - [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(LexboxLoginUrl))] - [ProducesResponseType(StatusCodes.Status403Forbidden)] + [ProducesResponseType(StatusCodes.Status302Found)] public IActionResult GetLexboxLoginUrl() { using var activity = OtelService.StartActivityWithTag(otelTagName, "getting lexbox login url"); - if (!_permissionService.IsCurrentUserAuthenticated(HttpContext)) - { - return Forbid(); - } - - var returnUrl = NormalizeReturnUrl(_configuration[PostLoginRedirectConfigKey]) + var redirectUrl = NormalizeReturnUrl(_configuration[PostLoginRedirectConfigKey]) ?? NormalizeReturnUrl(Domain.FrontendDomain) ?? "/"; - var loginUrl = QueryHelpers.AddQueryString("/v1/auth/lexbox-login", "returnUrl", returnUrl); - return Ok(new LexboxLoginUrl { Url = loginUrl }); + var authProperties = new AuthenticationProperties { RedirectUri = redirectUrl }; + + return Challenge(authProperties, LexboxOidcScheme); } /// Starts Lexbox OpenID Connect login challenge. diff --git a/Backend/Startup.cs b/Backend/Startup.cs index a97daf2e74..cea006fabe 100644 --- a/Backend/Startup.cs +++ b/Backend/Startup.cs @@ -134,11 +134,10 @@ public void ConfigureServices(IServiceCollection services) var key = ASCII.GetBytes(secretKey); - var lexboxBaseUrl = (Configuration["LexboxAuth:BaseUrl"] ?? "https://lexbox.org").TrimEnd('/'); - var lexboxAuthority = Configuration["LexboxAuth:Authority"] ?? lexboxBaseUrl; - var lexboxMetadataAddress = Configuration["LexboxAuth:OpenIdConfigUrl"] - ?? $"{lexboxBaseUrl}/.well-known/openid-configuration"; + var lexboxAuthority = (Configuration["LexboxAuth:Authority"] ?? "https://lexbox.org").TrimEnd('/'); 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"; var lexboxCallbackPath = Configuration["LexboxAuth:CallbackPath"] ?? "/v1/auth/oauth-callback"; diff --git a/Backend/appsettings.json b/Backend/appsettings.json index f3a24fdba4..7ae0f00eec 100644 --- a/Backend/appsettings.json +++ b/Backend/appsettings.json @@ -10,8 +10,9 @@ } }, "LexboxAuth": { - "BaseUrl": "https://lexbox.org", + "Authority": "https://lexbox.org", "ClientId": "the-combine", + "OpenIdConfigUrl": "https://lexbox.org/.well-known/openid-configuration", "Prompt": "select_account", "Scope": "profile openid offline_access sendandreceive" }, diff --git a/src/api/.openapi-generator/FILES b/src/api/.openapi-generator/FILES index a2d47d65e4..867c9d7e0d 100644 --- a/src/api/.openapi-generator/FILES +++ b/src/api/.openapi-generator/FILES @@ -40,7 +40,6 @@ models/gloss.ts models/gram-cat-group.ts models/grammatical-info.ts models/index.ts -models/lexbox-login-url.ts models/merge-source-word.ts models/merge-undo-ids.ts models/merge-words.ts diff --git a/src/api/api/auth-api.ts b/src/api/api/auth-api.ts index 9740d4310f..79632d0e61 100644 --- a/src/api/api/auth-api.ts +++ b/src/api/api/auth-api.ts @@ -38,8 +38,6 @@ import { } from "../base"; // @ts-ignore import { AuthStatus } from "../models"; -// @ts-ignore -import { LexboxLoginUrl } from "../models"; /** * AuthApi - axios parameter creator * @export @@ -201,7 +199,7 @@ export const AuthApiFp = function (configuration?: Configuration) { async getLexboxLoginUrl( options?: any ): Promise< - (axios?: AxiosInstance, basePath?: string) => AxiosPromise + (axios?: AxiosInstance, basePath?: string) => AxiosPromise > { const localVarAxiosArgs = await localVarAxiosParamCreator.getLexboxLoginUrl(options); @@ -262,7 +260,7 @@ export const AuthApiFactory = function ( * @param {*} [options] Override http request option. * @throws {RequiredError} */ - getLexboxLoginUrl(options?: any): AxiosPromise { + getLexboxLoginUrl(options?: any): AxiosPromise { return localVarFp .getLexboxLoginUrl(options) .then((request) => request(axios, basePath)); diff --git a/src/api/models/index.ts b/src/api/models/index.ts index 8bd391721d..0797af80a6 100644 --- a/src/api/models/index.ts +++ b/src/api/models/index.ts @@ -13,7 +13,6 @@ export * from "./flag"; export * from "./gloss"; export * from "./gram-cat-group"; export * from "./grammatical-info"; -export * from "./lexbox-login-url"; export * from "./merge-source-word"; export * from "./merge-undo-ids"; export * from "./merge-words"; diff --git a/src/api/models/lexbox-login-url.ts b/src/api/models/lexbox-login-url.ts deleted file mode 100644 index c093bdcd0a..0000000000 --- a/src/api/models/lexbox-login-url.ts +++ /dev/null @@ -1,27 +0,0 @@ -/* tslint:disable */ -/* eslint-disable */ -/** - * BackendFramework - * No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) - * - * The version of the OpenAPI document: 1.0 - * - * - * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). - * https://openapi-generator.tech - * Do not edit the class manually. - */ - -/** - * - * @export - * @interface LexboxLoginUrl - */ -export interface LexboxLoginUrl { - /** - * - * @type {string} - * @memberof LexboxLoginUrl - */ - url?: string | null; -} diff --git a/src/backend/index.ts b/src/backend/index.ts index 56b3722c44..088b6e3bd6 100644 --- a/src/backend/index.ts +++ b/src/backend/index.ts @@ -188,8 +188,7 @@ export async function getAuthStatus(): Promise { } export async function getExternalLoginUrl(): Promise { - const response = await authApi.getLexboxLoginUrl(defaultOptions()); - return response.data.url ?? ""; + return `${baseURL}/v1/auth/lexbox-login-url`; } export async function logoutCurrentUser(): Promise { From 2d39aa02d13f673ed0b99eb8e23f048bcbc8f71a Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Tue, 24 Feb 2026 09:49:58 -0500 Subject: [PATCH 13/38] Manually config --- Backend/Startup.cs | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/Backend/Startup.cs b/Backend/Startup.cs index cea006fabe..11c5baff76 100644 --- a/Backend/Startup.cs +++ b/Backend/Startup.cs @@ -16,6 +16,8 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; +using Microsoft.IdentityModel.Protocols; +using Microsoft.IdentityModel.Protocols.OpenIdConnect; using Microsoft.IdentityModel.Tokens; using static System.Text.Encoding; @@ -138,6 +140,12 @@ public void ConfigureServices(IServiceCollection services) var lexboxClientId = Configuration["LexboxAuth:ClientId"] ?? "the-combine"; var lexboxMetadataAddress = Configuration["LexboxAuth:OpenIdConfigUrl"] ?? "https://lexbox.org/.well-known/openid-configuration"; + var lexboxAuthorizationEndpoint = Configuration["LexboxAuth:AuthorizationEndpoint"] + ?? "https://lexbox.org/api/oauth/open-id-auth"; + var lexboxTokenEndpoint = Configuration["LexboxAuth:TokenEndpoint"] + ?? "https://lexbox.org/api/oauth/token"; + var lexboxUserInfoEndpoint = Configuration["LexboxAuth:UserInfoEndpoint"] + ?? "https://lexbox.org/api/oauth/userinfo"; var lexboxPrompt = Configuration["LexboxAuth:Prompt"] ?? "select_account"; var lexboxScope = Configuration["LexboxAuth:Scope"] ?? "profile openid offline_access sendandreceive"; var lexboxCallbackPath = Configuration["LexboxAuth:CallbackPath"] ?? "/v1/auth/oauth-callback"; @@ -171,9 +179,19 @@ public void ConfigureServices(IServiceCollection services) }) .AddOpenIdConnect("LexboxOidc", options => { + var issuer = $"{lexboxAuthority.TrimEnd('/')}/"; // Ensure ends with one slash. options.Authority = lexboxAuthority; options.CallbackPath = lexboxCallbackPath; options.ClientId = lexboxClientId; + options.Configuration = new OpenIdConnectConfiguration + { + AuthorizationEndpoint = lexboxAuthorizationEndpoint, + Issuer = issuer, + TokenEndpoint = lexboxTokenEndpoint, + UserInfoEndpoint = lexboxUserInfoEndpoint, + }; + options.ConfigurationManager = + new StaticConfigurationManager(options.Configuration); options.GetClaimsFromUserInfoEndpoint = true; options.MetadataAddress = lexboxMetadataAddress; options.RequireHttpsMetadata = true; From f78dbb0c33046d61774dfd58fcb4bd0cd85954cd Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Tue, 24 Feb 2026 10:04:23 -0500 Subject: [PATCH 14/38] Fix discovery --- .../Controllers/AuthControllerTests.cs | 28 ++++----- Backend/Controllers/AuthController.cs | 57 +++++++++++++++++-- Backend/Startup.cs | 27 +++------ Backend/appsettings.json | 1 + 4 files changed, 73 insertions(+), 40 deletions(-) diff --git a/Backend.Tests/Controllers/AuthControllerTests.cs b/Backend.Tests/Controllers/AuthControllerTests.cs index 737beb5aeb..9ecbc37127 100644 --- a/Backend.Tests/Controllers/AuthControllerTests.cs +++ b/Backend.Tests/Controllers/AuthControllerTests.cs @@ -80,39 +80,31 @@ public async Task GetAuthStatusReturnsLoggedOutWhenNotAuthenticatedByLexboxCooki } [Test] - public void GetLexboxLoginUrlReturnsExpectedLoginPath() + public async Task GetLexboxLoginUrlReturnsExpectedLoginPath() { - _controller.ControllerContext.HttpContext = PermissionServiceMock.HttpContextWithUserId(UserId); + _controller.ControllerContext.HttpContext = GetAuthContext(AuthenticateResult.NoResult()); - var challengeResult = _controller.GetLexboxLoginUrl() as ChallengeResult; + var result = await _controller.GetLexboxLoginUrl(); - Assert.That(challengeResult, Is.Not.Null); - Assert.That(challengeResult.AuthenticationSchemes, Has.Count.EqualTo(1)); - Assert.That(challengeResult.AuthenticationSchemes[0], Is.EqualTo("LexboxOidc")); - Assert.That(challengeResult.Properties, Is.Not.Null); - Assert.That(challengeResult.Properties.RedirectUri, Is.EqualTo("/")); + Assert.That(result, Is.InstanceOf()); } [Test] - public void StartLexboxLoginReturnsChallengeWithConfiguredSchemeAndRedirect() + public async Task StartLexboxLoginReturnsChallengeWithConfiguredSchemeAndRedirect() { - _controller.ControllerContext.HttpContext = PermissionServiceMock.HttpContextWithUserId(UserId); + _controller.ControllerContext.HttpContext = GetAuthContext(AuthenticateResult.NoResult()); - var challengeResult = _controller.StartLexboxLogin("/after-login") as ChallengeResult; + var result = await _controller.StartLexboxLogin("/after-login"); - Assert.That(challengeResult, Is.Not.Null); - Assert.That(challengeResult.AuthenticationSchemes, Has.Count.EqualTo(1)); - Assert.That(challengeResult.AuthenticationSchemes[0], Is.EqualTo("LexboxOidc")); - Assert.That(challengeResult.Properties, Is.Not.Null); - Assert.That(challengeResult.Properties.RedirectUri, Is.EqualTo("/after-login")); + Assert.That(result, Is.InstanceOf()); } [Test] - public void StartLexboxLoginUnauthorizedReturnsForbid() + public async Task StartLexboxLoginUnauthorizedReturnsForbid() { _controller.ControllerContext.HttpContext = PermissionServiceMock.UnauthorizedHttpContext(); - var result = _controller.StartLexboxLogin("/after-login"); + var result = await _controller.StartLexboxLogin("/after-login"); Assert.That(result, Is.InstanceOf()); } diff --git a/Backend/Controllers/AuthController.cs b/Backend/Controllers/AuthController.cs index 14bcc4ce6c..e6a799f397 100644 --- a/Backend/Controllers/AuthController.cs +++ b/Backend/Controllers/AuthController.cs @@ -5,9 +5,11 @@ using BackendFramework.Models; using BackendFramework.Otel; using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authentication.OpenIdConnect; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Options; namespace BackendFramework.Controllers { @@ -49,7 +51,8 @@ public async Task GetAuthStatus() /// Generates a Lexbox login URL for OIDC sign-in. [HttpGet("lexbox-login-url", Name = "GetLexboxLoginUrl")] [ProducesResponseType(StatusCodes.Status302Found)] - public IActionResult GetLexboxLoginUrl() + [ProducesResponseType(StatusCodes.Status500InternalServerError)] + public async Task GetLexboxLoginUrl() { using var activity = OtelService.StartActivityWithTag(otelTagName, "getting lexbox login url"); @@ -58,14 +61,15 @@ public IActionResult GetLexboxLoginUrl() ?? "/"; var authProperties = new AuthenticationProperties { RedirectUri = redirectUrl }; - return Challenge(authProperties, LexboxOidcScheme); + return await ChallengeLexboxAsync(authProperties, "lexbox-login-url"); } /// Starts Lexbox OpenID Connect login challenge. [HttpGet("lexbox-login", Name = "StartLexboxLogin")] [ProducesResponseType(StatusCodes.Status302Found)] [ProducesResponseType(StatusCodes.Status403Forbidden)] - public IActionResult StartLexboxLogin([FromQuery] string? returnUrl) + [ProducesResponseType(StatusCodes.Status500InternalServerError)] + public async Task StartLexboxLogin([FromQuery] string? returnUrl) { using var activity = OtelService.StartActivityWithTag(otelTagName, "starting lexbox login"); @@ -79,7 +83,52 @@ public IActionResult StartLexboxLogin([FromQuery] string? returnUrl) Console.WriteLine($"Redirect URL for OIDC login: {redirectUrl}"); var authProperties = new AuthenticationProperties { RedirectUri = redirectUrl ?? "/" }; - return Challenge(authProperties, LexboxOidcScheme); + return await ChallengeLexboxAsync(authProperties, "lexbox-login"); + } + + private async Task ChallengeLexboxAsync(AuthenticationProperties authProperties, string source) + { + try + { + await HttpContext.ChallengeAsync(LexboxOidcScheme, authProperties); + return new EmptyResult(); + } + catch (Exception ex) + { + var authority = _configuration["LexboxAuth:Authority"] ?? "(null)"; + var metadataAddress = _configuration["LexboxAuth:OpenIdConfigUrl"] ?? "(null)"; + var callbackPath = _configuration["LexboxAuth:CallbackPath"] ?? "/v1/auth/oauth-callback"; + var configurationDiagnostic = "OpenIdConnect configuration manager not available."; + + var optionsMonitor = HttpContext.RequestServices.GetService(typeof(IOptionsMonitor)) + as IOptionsMonitor; + if (optionsMonitor is not null) + { + var options = optionsMonitor.Get(LexboxOidcScheme); + if (options.ConfigurationManager is not null) + { + try + { + var discovered = await options.ConfigurationManager.GetConfigurationAsync( + HttpContext.RequestAborted); + configurationDiagnostic = + $"Discovery loaded. Issuer={discovered.Issuer}, AuthorizationEndpoint={discovered.AuthorizationEndpoint}, TokenEndpoint={discovered.TokenEndpoint}, UserInfoEndpoint={discovered.UserInfoEndpoint}"; + } + catch (Exception discoveryEx) + { + configurationDiagnostic = + $"Discovery retrieval failed: {discoveryEx}"; + } + } + } + + Console.Error.WriteLine( + $"Lexbox OIDC challenge failed from {source}. Authority={authority}, Metadata={metadataAddress}, CallbackPath={callbackPath}. ConfigDiagnostic={configurationDiagnostic}. Exception={ex}"); + return Problem( + title: "Lexbox OIDC challenge failed", + detail: $"{ex}\n\n{configurationDiagnostic}", + statusCode: StatusCodes.Status500InternalServerError); + } } private static string? NormalizeReturnUrl(string? url) diff --git a/Backend/Startup.cs b/Backend/Startup.cs index 11c5baff76..95b55c6002 100644 --- a/Backend/Startup.cs +++ b/Backend/Startup.cs @@ -16,8 +16,6 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; -using Microsoft.IdentityModel.Protocols; -using Microsoft.IdentityModel.Protocols.OpenIdConnect; using Microsoft.IdentityModel.Tokens; using static System.Text.Encoding; @@ -140,12 +138,10 @@ public void ConfigureServices(IServiceCollection services) var lexboxClientId = Configuration["LexboxAuth:ClientId"] ?? "the-combine"; var lexboxMetadataAddress = Configuration["LexboxAuth:OpenIdConfigUrl"] ?? "https://lexbox.org/.well-known/openid-configuration"; - var lexboxAuthorizationEndpoint = Configuration["LexboxAuth:AuthorizationEndpoint"] - ?? "https://lexbox.org/api/oauth/open-id-auth"; - var lexboxTokenEndpoint = Configuration["LexboxAuth:TokenEndpoint"] - ?? "https://lexbox.org/api/oauth/token"; - var lexboxUserInfoEndpoint = Configuration["LexboxAuth:UserInfoEndpoint"] - ?? "https://lexbox.org/api/oauth/userinfo"; + var lexboxAuthorizationEndpoint = Configuration["LexboxAuth:AuthorizationEndpoint"]?.Trim(); + lexboxAuthorizationEndpoint = string.IsNullOrEmpty(lexboxAuthorizationEndpoint) + ? "https://lexbox.org/api/oauth/open-id-auth" + : lexboxAuthorizationEndpoint; var lexboxPrompt = Configuration["LexboxAuth:Prompt"] ?? "select_account"; var lexboxScope = Configuration["LexboxAuth:Scope"] ?? "profile openid offline_access sendandreceive"; var lexboxCallbackPath = Configuration["LexboxAuth:CallbackPath"] ?? "/v1/auth/oauth-callback"; @@ -179,19 +175,9 @@ public void ConfigureServices(IServiceCollection services) }) .AddOpenIdConnect("LexboxOidc", options => { - var issuer = $"{lexboxAuthority.TrimEnd('/')}/"; // Ensure ends with one slash. options.Authority = lexboxAuthority; options.CallbackPath = lexboxCallbackPath; options.ClientId = lexboxClientId; - options.Configuration = new OpenIdConnectConfiguration - { - AuthorizationEndpoint = lexboxAuthorizationEndpoint, - Issuer = issuer, - TokenEndpoint = lexboxTokenEndpoint, - UserInfoEndpoint = lexboxUserInfoEndpoint, - }; - options.ConfigurationManager = - new StaticConfigurationManager(options.Configuration); options.GetClaimsFromUserInfoEndpoint = true; options.MetadataAddress = lexboxMetadataAddress; options.RequireHttpsMetadata = true; @@ -208,6 +194,11 @@ public void ConfigureServices(IServiceCollection services) options.Events.OnRedirectToIdentityProvider = context => { + if (string.IsNullOrWhiteSpace(context.ProtocolMessage.IssuerAddress)) + { + context.ProtocolMessage.IssuerAddress = lexboxAuthorizationEndpoint; + } + context.ProtocolMessage.Prompt = lexboxPrompt; return System.Threading.Tasks.Task.CompletedTask; }; diff --git a/Backend/appsettings.json b/Backend/appsettings.json index 7ae0f00eec..539118c340 100644 --- a/Backend/appsettings.json +++ b/Backend/appsettings.json @@ -11,6 +11,7 @@ }, "LexboxAuth": { "Authority": "https://lexbox.org", + "AuthorizationEndpoint": "https://lexbox.org/api/oauth/open-id-auth", "ClientId": "the-combine", "OpenIdConfigUrl": "https://lexbox.org/.well-known/openid-configuration", "Prompt": "select_account", From a55747d0c90c1b339bfc857310a2e9b14cc08336 Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Tue, 24 Feb 2026 10:23:11 -0500 Subject: [PATCH 15/38] Tidy --- Backend/Controllers/AuthController.cs | 42 +++------------------------ Backend/Startup.cs | 12 ++++---- Backend/appsettings.json | 8 ----- 3 files changed, 11 insertions(+), 51 deletions(-) diff --git a/Backend/Controllers/AuthController.cs b/Backend/Controllers/AuthController.cs index e6a799f397..864737a8f3 100644 --- a/Backend/Controllers/AuthController.cs +++ b/Backend/Controllers/AuthController.cs @@ -5,11 +5,9 @@ using BackendFramework.Models; using BackendFramework.Otel; using Microsoft.AspNetCore.Authentication; -using Microsoft.AspNetCore.Authentication.OpenIdConnect; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.Options; namespace BackendFramework.Controllers { @@ -61,7 +59,7 @@ public async Task GetLexboxLoginUrl() ?? "/"; var authProperties = new AuthenticationProperties { RedirectUri = redirectUrl }; - return await ChallengeLexboxAsync(authProperties, "lexbox-login-url"); + return await ChallengeLexboxAsync(authProperties); } /// Starts Lexbox OpenID Connect login challenge. @@ -80,13 +78,12 @@ public async Task StartLexboxLogin([FromQuery] string? returnUrl) var redirectUrl = NormalizeReturnUrl(returnUrl) ?? NormalizeReturnUrl(_configuration[PostLoginRedirectConfigKey]); - Console.WriteLine($"Redirect URL for OIDC login: {redirectUrl}"); var authProperties = new AuthenticationProperties { RedirectUri = redirectUrl ?? "/" }; - return await ChallengeLexboxAsync(authProperties, "lexbox-login"); + return await ChallengeLexboxAsync(authProperties); } - private async Task ChallengeLexboxAsync(AuthenticationProperties authProperties, string source) + private async Task ChallengeLexboxAsync(AuthenticationProperties authProperties) { try { @@ -95,38 +92,7 @@ private async Task ChallengeLexboxAsync(AuthenticationProperties } catch (Exception ex) { - var authority = _configuration["LexboxAuth:Authority"] ?? "(null)"; - var metadataAddress = _configuration["LexboxAuth:OpenIdConfigUrl"] ?? "(null)"; - var callbackPath = _configuration["LexboxAuth:CallbackPath"] ?? "/v1/auth/oauth-callback"; - var configurationDiagnostic = "OpenIdConnect configuration manager not available."; - - var optionsMonitor = HttpContext.RequestServices.GetService(typeof(IOptionsMonitor)) - as IOptionsMonitor; - if (optionsMonitor is not null) - { - var options = optionsMonitor.Get(LexboxOidcScheme); - if (options.ConfigurationManager is not null) - { - try - { - var discovered = await options.ConfigurationManager.GetConfigurationAsync( - HttpContext.RequestAborted); - configurationDiagnostic = - $"Discovery loaded. Issuer={discovered.Issuer}, AuthorizationEndpoint={discovered.AuthorizationEndpoint}, TokenEndpoint={discovered.TokenEndpoint}, UserInfoEndpoint={discovered.UserInfoEndpoint}"; - } - catch (Exception discoveryEx) - { - configurationDiagnostic = - $"Discovery retrieval failed: {discoveryEx}"; - } - } - } - - Console.Error.WriteLine( - $"Lexbox OIDC challenge failed from {source}. Authority={authority}, Metadata={metadataAddress}, CallbackPath={callbackPath}. ConfigDiagnostic={configurationDiagnostic}. Exception={ex}"); - return Problem( - title: "Lexbox OIDC challenge failed", - detail: $"{ex}\n\n{configurationDiagnostic}", + return Problem(title: "Lexbox OIDC challenge failed", detail: ex.Message, statusCode: StatusCodes.Status500InternalServerError); } } diff --git a/Backend/Startup.cs b/Backend/Startup.cs index 95b55c6002..88493cc731 100644 --- a/Backend/Startup.cs +++ b/Backend/Startup.cs @@ -134,17 +134,19 @@ public void ConfigureServices(IServiceCollection services) var key = ASCII.GetBytes(secretKey); - var lexboxAuthority = (Configuration["LexboxAuth:Authority"] ?? "https://lexbox.org").TrimEnd('/'); - var lexboxClientId = Configuration["LexboxAuth:ClientId"] ?? "the-combine"; - var lexboxMetadataAddress = Configuration["LexboxAuth:OpenIdConfigUrl"] - ?? "https://lexbox.org/.well-known/openid-configuration"; + 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"; - var lexboxCallbackPath = Configuration["LexboxAuth:CallbackPath"] ?? "/v1/auth/oauth-callback"; services.AddAuthentication(x => { diff --git a/Backend/appsettings.json b/Backend/appsettings.json index 539118c340..f03b8c6e7f 100644 --- a/Backend/appsettings.json +++ b/Backend/appsettings.json @@ -9,13 +9,5 @@ "Default": "Information" } }, - "LexboxAuth": { - "Authority": "https://lexbox.org", - "AuthorizationEndpoint": "https://lexbox.org/api/oauth/open-id-auth", - "ClientId": "the-combine", - "OpenIdConfigUrl": "https://lexbox.org/.well-known/openid-configuration", - "Prompt": "select_account", - "Scope": "profile openid offline_access sendandreceive" - }, "AllowedHosts": "*" } From 39b518db58ac28e267449779fd4c83cbcb8a56d2 Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Tue, 24 Feb 2026 11:17:27 -0500 Subject: [PATCH 16/38] Fix frontend test --- src/components/Lexbox/tests/LexboxLogin.test.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/components/Lexbox/tests/LexboxLogin.test.tsx b/src/components/Lexbox/tests/LexboxLogin.test.tsx index 1758e2b761..1c45f5c45c 100644 --- a/src/components/Lexbox/tests/LexboxLogin.test.tsx +++ b/src/components/Lexbox/tests/LexboxLogin.test.tsx @@ -24,7 +24,7 @@ describe("LexboxLogin", () => { }); it("redirects to Lexbox login when logged out", async () => { - mockGetAuthStatus.mockResolvedValue({ loggedIn: false }); + mockGetAuthStatus.mockResolvedValue({ isLoggedIn: false }); await act(async () => { render(); @@ -42,8 +42,8 @@ describe("LexboxLogin", () => { it("shows logged-in menu and logs out", async () => { mockGetAuthStatus - .mockResolvedValueOnce({ loggedIn: true, loggedInAs: "Lex User" }) - .mockResolvedValueOnce({ loggedIn: false }); + .mockResolvedValueOnce({ isLoggedIn: true, loggedInAs: "Lex User" }) + .mockResolvedValueOnce({ isLoggedIn: false }); const onStatusChange = jest.fn(); From 6a298e3fab3262cc5ad50d4833bb3035c4b32884 Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Tue, 24 Feb 2026 11:44:42 -0500 Subject: [PATCH 17/38] Return redundant controller method --- .../Controllers/AuthControllerTests.cs | 20 --- Backend/Controllers/AuthController.cs | 25 +--- src/api/api/auth-api.ts | 120 +----------------- src/backend/index.ts | 12 +- src/components/Lexbox/LexboxLogin.tsx | 25 ++-- .../Lexbox/tests/LexboxLogin.test.tsx | 26 ++-- 6 files changed, 34 insertions(+), 194 deletions(-) diff --git a/Backend.Tests/Controllers/AuthControllerTests.cs b/Backend.Tests/Controllers/AuthControllerTests.cs index 9ecbc37127..b77a6b9c2d 100644 --- a/Backend.Tests/Controllers/AuthControllerTests.cs +++ b/Backend.Tests/Controllers/AuthControllerTests.cs @@ -89,26 +89,6 @@ public async Task GetLexboxLoginUrlReturnsExpectedLoginPath() Assert.That(result, Is.InstanceOf()); } - [Test] - public async Task StartLexboxLoginReturnsChallengeWithConfiguredSchemeAndRedirect() - { - _controller.ControllerContext.HttpContext = GetAuthContext(AuthenticateResult.NoResult()); - - var result = await _controller.StartLexboxLogin("/after-login"); - - Assert.That(result, Is.InstanceOf()); - } - - [Test] - public async Task StartLexboxLoginUnauthorizedReturnsForbid() - { - _controller.ControllerContext.HttpContext = PermissionServiceMock.UnauthorizedHttpContext(); - - var result = await _controller.StartLexboxLogin("/after-login"); - - Assert.That(result, Is.InstanceOf()); - } - private static HttpContext GetAuthContext(AuthenticateResult authenticateResult) { var context = PermissionServiceMock.HttpContextWithUserId(UserId); diff --git a/Backend/Controllers/AuthController.cs b/Backend/Controllers/AuthController.cs index 864737a8f3..bb1b826939 100644 --- a/Backend/Controllers/AuthController.cs +++ b/Backend/Controllers/AuthController.cs @@ -47,12 +47,12 @@ public async Task GetAuthStatus() } /// Generates a Lexbox login URL for OIDC sign-in. - [HttpGet("lexbox-login-url", Name = "GetLexboxLoginUrl")] + [HttpGet("lexbox-login", Name = "GetLexboxLogin")] [ProducesResponseType(StatusCodes.Status302Found)] [ProducesResponseType(StatusCodes.Status500InternalServerError)] public async Task GetLexboxLoginUrl() { - using var activity = OtelService.StartActivityWithTag(otelTagName, "getting lexbox login url"); + using var activity = OtelService.StartActivityWithTag(otelTagName, "getting lexbox login"); var redirectUrl = NormalizeReturnUrl(_configuration[PostLoginRedirectConfigKey]) ?? NormalizeReturnUrl(Domain.FrontendDomain) @@ -62,27 +62,6 @@ public async Task GetLexboxLoginUrl() return await ChallengeLexboxAsync(authProperties); } - /// Starts Lexbox OpenID Connect login challenge. - [HttpGet("lexbox-login", Name = "StartLexboxLogin")] - [ProducesResponseType(StatusCodes.Status302Found)] - [ProducesResponseType(StatusCodes.Status403Forbidden)] - [ProducesResponseType(StatusCodes.Status500InternalServerError)] - public async Task StartLexboxLogin([FromQuery] string? returnUrl) - { - using var activity = OtelService.StartActivityWithTag(otelTagName, "starting lexbox login"); - - if (!_permissionService.IsCurrentUserAuthenticated(HttpContext)) - { - return Forbid(); - } - - var redirectUrl = NormalizeReturnUrl(returnUrl) - ?? NormalizeReturnUrl(_configuration[PostLoginRedirectConfigKey]); - var authProperties = new AuthenticationProperties { RedirectUri = redirectUrl ?? "/" }; - - return await ChallengeLexboxAsync(authProperties); - } - private async Task ChallengeLexboxAsync(AuthenticationProperties authProperties) { try diff --git a/src/api/api/auth-api.ts b/src/api/api/auth-api.ts index 79632d0e61..744147eaa9 100644 --- a/src/api/api/auth-api.ts +++ b/src/api/api/auth-api.ts @@ -87,47 +87,7 @@ export const AuthApiAxiosParamCreator = function ( * @param {*} [options] Override http request option. * @throws {RequiredError} */ - getLexboxLoginUrl: async (options: any = {}): Promise => { - const localVarPath = `/v1/auth/lexbox-login-url`; - // use dummy base URL string because the URL constructor only accepts absolute URLs. - const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); - let baseOptions; - if (configuration) { - baseOptions = configuration.baseOptions; - } - - const localVarRequestOptions = { - method: "GET", - ...baseOptions, - ...options, - }; - const localVarHeaderParameter = {} as any; - const localVarQueryParameter = {} as any; - - setSearchParams(localVarUrlObj, localVarQueryParameter, options.query); - let headersFromBaseOptions = - baseOptions && baseOptions.headers ? baseOptions.headers : {}; - localVarRequestOptions.headers = { - ...localVarHeaderParameter, - ...headersFromBaseOptions, - ...options.headers, - }; - - return { - url: toPathString(localVarUrlObj), - options: localVarRequestOptions, - }; - }, - /** - * - * @param {string} [returnUrl] - * @param {*} [options] Override http request option. - * @throws {RequiredError} - */ - startLexboxLogin: async ( - returnUrl?: string, - options: any = {} - ): Promise => { + getLexboxLogin: async (options: any = {}): Promise => { const localVarPath = `/v1/auth/lexbox-login`; // use dummy base URL string because the URL constructor only accepts absolute URLs. const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); @@ -144,10 +104,6 @@ export const AuthApiAxiosParamCreator = function ( const localVarHeaderParameter = {} as any; const localVarQueryParameter = {} as any; - if (returnUrl !== undefined) { - localVarQueryParameter["returnUrl"] = returnUrl; - } - setSearchParams(localVarUrlObj, localVarQueryParameter, options.query); let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; @@ -196,34 +152,13 @@ export const AuthApiFp = function (configuration?: Configuration) { * @param {*} [options] Override http request option. * @throws {RequiredError} */ - async getLexboxLoginUrl( + async getLexboxLogin( options?: any ): Promise< (axios?: AxiosInstance, basePath?: string) => AxiosPromise > { const localVarAxiosArgs = - await localVarAxiosParamCreator.getLexboxLoginUrl(options); - return createRequestFunction( - localVarAxiosArgs, - globalAxios, - BASE_PATH, - configuration - ); - }, - /** - * - * @param {string} [returnUrl] - * @param {*} [options] Override http request option. - * @throws {RequiredError} - */ - async startLexboxLogin( - returnUrl?: string, - options?: any - ): Promise< - (axios?: AxiosInstance, basePath?: string) => AxiosPromise - > { - const localVarAxiosArgs = - await localVarAxiosParamCreator.startLexboxLogin(returnUrl, options); + await localVarAxiosParamCreator.getLexboxLogin(options); return createRequestFunction( localVarAxiosArgs, globalAxios, @@ -260,39 +195,14 @@ export const AuthApiFactory = function ( * @param {*} [options] Override http request option. * @throws {RequiredError} */ - getLexboxLoginUrl(options?: any): AxiosPromise { + getLexboxLogin(options?: any): AxiosPromise { return localVarFp - .getLexboxLoginUrl(options) - .then((request) => request(axios, basePath)); - }, - /** - * - * @param {string} [returnUrl] - * @param {*} [options] Override http request option. - * @throws {RequiredError} - */ - startLexboxLogin(returnUrl?: string, options?: any): AxiosPromise { - return localVarFp - .startLexboxLogin(returnUrl, options) + .getLexboxLogin(options) .then((request) => request(axios, basePath)); }, }; }; -/** - * Request parameters for startLexboxLogin operation in AuthApi. - * @export - * @interface AuthApiStartLexboxLoginRequest - */ -export interface AuthApiStartLexboxLoginRequest { - /** - * - * @type {string} - * @memberof AuthApiStartLexboxLogin - */ - readonly returnUrl?: string; -} - /** * AuthApi - object-oriented interface * @export @@ -318,25 +228,9 @@ export class AuthApi extends BaseAPI { * @throws {RequiredError} * @memberof AuthApi */ - public getLexboxLoginUrl(options?: any) { - return AuthApiFp(this.configuration) - .getLexboxLoginUrl(options) - .then((request) => request(this.axios, this.basePath)); - } - - /** - * - * @param {AuthApiStartLexboxLoginRequest} requestParameters Request parameters. - * @param {*} [options] Override http request option. - * @throws {RequiredError} - * @memberof AuthApi - */ - public startLexboxLogin( - requestParameters: AuthApiStartLexboxLoginRequest = {}, - options?: any - ) { + public getLexboxLogin(options?: any) { return AuthApiFp(this.configuration) - .startLexboxLogin(requestParameters.returnUrl, options) + .getLexboxLogin(options) .then((request) => request(this.axios, this.basePath)); } } diff --git a/src/backend/index.ts b/src/backend/index.ts index 088b6e3bd6..8fc1a394cd 100644 --- a/src/backend/index.ts +++ b/src/backend/index.ts @@ -183,22 +183,18 @@ export function getAudioUrl(wordId: string, fileName: string): string { /* AuthController.cs */ -export async function getAuthStatus(): Promise { +export async function getLexboxAuthStatus(): Promise { return (await authApi.getAuthStatus(defaultOptions())).data; } -export async function getExternalLoginUrl(): Promise { - return `${baseURL}/v1/auth/lexbox-login-url`; +export function getLexboxLoginUrl(): string { + return `${baseURL}/v1/auth/lexbox-login`; } -export async function logoutCurrentUser(): Promise { +export async function logoutLexboxUser(): Promise { return; } -export async function startLexboxLogin(): Promise { - await authApi.startLexboxLogin(undefined, defaultOptions()); -} - /* AvatarController.cs */ /** Uploads avatar for current user. */ diff --git a/src/components/Lexbox/LexboxLogin.tsx b/src/components/Lexbox/LexboxLogin.tsx index 485205c5b1..27f13cd512 100644 --- a/src/components/Lexbox/LexboxLogin.tsx +++ b/src/components/Lexbox/LexboxLogin.tsx @@ -11,7 +11,11 @@ import { ReactElement, useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { type AuthStatus } from "api/models"; -import { getAuthStatus, getExternalLoginUrl, logoutCurrentUser } from "backend"; +import { + getLexboxAuthStatus, + getLexboxLoginUrl, + logoutLexboxUser, +} from "backend"; import LoadingButton from "components/Buttons/LoadingButton"; interface LexboxLoginProps { @@ -29,7 +33,7 @@ export default function LexboxLogin(props: LexboxLoginProps): ReactElement { const loadStatus = async (): Promise => { setStatusLoading(true); try { - setStatus(await getAuthStatus()); + setStatus(await getLexboxAuthStatus()); } catch (err) { console.error("Failed to load auth status", err); setStatus(undefined); @@ -43,26 +47,13 @@ export default function LexboxLogin(props: LexboxLoginProps): ReactElement { }, []); const handleLogin = async (): Promise => { - setActionLoading(true); - try { - const url = await getExternalLoginUrl(); - console.info("Opening Lexbox login URL:", url); - if (url) { - window.open(url); - } else { - console.error("Lexbox login URL is empty"); - } - } catch (err) { - console.error("Failed to get Lexbox login URL", err); - } finally { - setActionLoading(false); - } + window.open(getLexboxLoginUrl()); }; const handleLogout = async (): Promise => { setActionLoading(true); try { - logoutCurrentUser(); + await logoutLexboxUser(); await loadStatus(); props.onStatusChange?.("logged-out"); } finally { diff --git a/src/components/Lexbox/tests/LexboxLogin.test.tsx b/src/components/Lexbox/tests/LexboxLogin.test.tsx index 1c45f5c45c..d533a3abcf 100644 --- a/src/components/Lexbox/tests/LexboxLogin.test.tsx +++ b/src/components/Lexbox/tests/LexboxLogin.test.tsx @@ -5,14 +5,14 @@ import userEvent from "@testing-library/user-event"; import LexboxLogin from "components/Lexbox/LexboxLogin"; jest.mock("backend", () => ({ - getAuthStatus: () => mockGetAuthStatus(), - getExternalLoginUrl: () => mockGetExternalLoginUrl(), - logoutCurrentUser: () => mockLogoutCurrentUser(), + getLexboxAuthStatus: () => mockGetLexboxAuthStatus(), + getLexboxLoginUrl: () => mockGetLexboxLoginUrl(), + logoutLexboxUser: () => mockLogoutLexboxUser(), })); -const mockGetAuthStatus = jest.fn(); -const mockGetExternalLoginUrl = jest.fn(); -const mockLogoutCurrentUser = jest.fn(); +const mockGetLexboxAuthStatus = jest.fn(); +const mockGetLexboxLoginUrl = jest.fn(); +const mockLogoutLexboxUser = jest.fn(); const testUrl = "not-a-valid-url"; @@ -20,28 +20,28 @@ describe("LexboxLogin", () => { beforeEach(() => { jest.clearAllMocks(); jest.spyOn(window, "open").mockImplementation(() => null); - mockGetExternalLoginUrl.mockResolvedValue(testUrl); + mockGetLexboxLoginUrl.mockReturnValue(testUrl); }); it("redirects to Lexbox login when logged out", async () => { - mockGetAuthStatus.mockResolvedValue({ isLoggedIn: false }); + mockGetLexboxAuthStatus.mockResolvedValue({ isLoggedIn: false }); await act(async () => { render(); }); const loginButton = await screen.findByRole("button", { name: /login/i }); - await waitFor(() => expect(mockGetAuthStatus).toHaveBeenCalled()); + await waitFor(() => expect(mockGetLexboxAuthStatus).toHaveBeenCalled()); await waitFor(() => expect(loginButton).toBeEnabled()); await userEvent.click(loginButton); - expect(mockGetExternalLoginUrl).toHaveBeenCalledTimes(1); + expect(mockGetLexboxLoginUrl).toHaveBeenCalledTimes(1); expect(window.open).toHaveBeenCalledWith(testUrl); }); it("shows logged-in menu and logs out", async () => { - mockGetAuthStatus + mockGetLexboxAuthStatus .mockResolvedValueOnce({ isLoggedIn: true, loggedInAs: "Lex User" }) .mockResolvedValueOnce({ isLoggedIn: false }); @@ -61,8 +61,8 @@ describe("LexboxLogin", () => { await userEvent.click(logoutItem); - expect(mockGetExternalLoginUrl).not.toHaveBeenCalled(); - expect(mockLogoutCurrentUser).toHaveBeenCalledTimes(1); + expect(mockGetLexboxLoginUrl).not.toHaveBeenCalled(); + expect(mockLogoutLexboxUser).toHaveBeenCalledTimes(1); expect(onStatusChange).toHaveBeenCalledWith("logged-out"); }); }); From 37f1a1ae5f4895e04f234827ae9c0fd5862f89c0 Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Tue, 24 Feb 2026 12:13:34 -0500 Subject: [PATCH 18/38] Add logout endpoint; Tidy React component --- .../Controllers/AuthControllerTests.cs | 17 +++- Backend/Controllers/AuthController.cs | 17 +++- Backend/Models/LexboxLoginUrl.cs | 7 -- src/api/api/auth-api.ts | 77 +++++++++++++++++++ src/backend/index.ts | 2 +- src/components/Lexbox/LexboxLogin.tsx | 20 +++-- 6 files changed, 117 insertions(+), 23 deletions(-) delete mode 100644 Backend/Models/LexboxLoginUrl.cs diff --git a/Backend.Tests/Controllers/AuthControllerTests.cs b/Backend.Tests/Controllers/AuthControllerTests.cs index b77a6b9c2d..c8a622dc8d 100644 --- a/Backend.Tests/Controllers/AuthControllerTests.cs +++ b/Backend.Tests/Controllers/AuthControllerTests.cs @@ -80,15 +80,28 @@ public async Task GetAuthStatusReturnsLoggedOutWhenNotAuthenticatedByLexboxCooki } [Test] - public async Task GetLexboxLoginUrlReturnsExpectedLoginPath() + public async Task GetLexboxLoginReturnsExpectedLoginPath() { _controller.ControllerContext.HttpContext = GetAuthContext(AuthenticateResult.NoResult()); - var result = await _controller.GetLexboxLoginUrl(); + var result = await _controller.GetLexboxLogin(); Assert.That(result, Is.InstanceOf()); } + [Test] + public async Task LogOutLexboxReturnsNoContent() + { + var claims = new List { 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()); + } + private static HttpContext GetAuthContext(AuthenticateResult authenticateResult) { var context = PermissionServiceMock.HttpContextWithUserId(UserId); diff --git a/Backend/Controllers/AuthController.cs b/Backend/Controllers/AuthController.cs index bb1b826939..4e8c4890a2 100644 --- a/Backend/Controllers/AuthController.cs +++ b/Backend/Controllers/AuthController.cs @@ -46,11 +46,11 @@ public async Task GetAuthStatus() return user is null ? Ok(AuthStatus.LoggedOut()) : Ok(AuthStatus.LoggedIn(user)); } - /// Generates a Lexbox login URL for OIDC sign-in. + /// Generates a redirect to Lexbox login for OIDC sign-in. [HttpGet("lexbox-login", Name = "GetLexboxLogin")] [ProducesResponseType(StatusCodes.Status302Found)] [ProducesResponseType(StatusCodes.Status500InternalServerError)] - public async Task GetLexboxLoginUrl() + public async Task GetLexboxLogin() { using var activity = OtelService.StartActivityWithTag(otelTagName, "getting lexbox login"); @@ -62,6 +62,19 @@ public async Task GetLexboxLoginUrl() return await ChallengeLexboxAsync(authProperties); } + /// Signs out the current user from Lexbox cookie and OIDC. + [HttpGet("lexbox-logout", Name = "LogOutLexbox")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + public async Task LogOutLexbox() + { + using var activity = OtelService.StartActivityWithTag(otelTagName, "logging out"); + + await HttpContext.SignOutAsync(LexboxCookieScheme); + await HttpContext.SignOutAsync(LexboxOidcScheme); + + return NoContent(); + } + private async Task ChallengeLexboxAsync(AuthenticationProperties authProperties) { try diff --git a/Backend/Models/LexboxLoginUrl.cs b/Backend/Models/LexboxLoginUrl.cs deleted file mode 100644 index 85b9fa31c1..0000000000 --- a/Backend/Models/LexboxLoginUrl.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace BackendFramework.Models -{ - public class LexboxLoginUrl - { - public string Url { get; set; } = ""; - } -} diff --git a/src/api/api/auth-api.ts b/src/api/api/auth-api.ts index 744147eaa9..d8870384d8 100644 --- a/src/api/api/auth-api.ts +++ b/src/api/api/auth-api.ts @@ -113,6 +113,42 @@ export const AuthApiAxiosParamCreator = function ( ...options.headers, }; + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, + /** + * + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + logOutLexbox: async (options: any = {}): Promise => { + const localVarPath = `/v1/auth/lexbox-logout`; + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { + method: "GET", + ...baseOptions, + ...options, + }; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + setSearchParams(localVarUrlObj, localVarQueryParameter, options.query); + let headersFromBaseOptions = + baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = { + ...localVarHeaderParameter, + ...headersFromBaseOptions, + ...options.headers, + }; + return { url: toPathString(localVarUrlObj), options: localVarRequestOptions, @@ -166,6 +202,25 @@ export const AuthApiFp = function (configuration?: Configuration) { configuration ); }, + /** + * + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async logOutLexbox( + options?: any + ): Promise< + (axios?: AxiosInstance, basePath?: string) => AxiosPromise + > { + const localVarAxiosArgs = + await localVarAxiosParamCreator.logOutLexbox(options); + return createRequestFunction( + localVarAxiosArgs, + globalAxios, + BASE_PATH, + configuration + ); + }, }; }; @@ -200,6 +255,16 @@ export const AuthApiFactory = function ( .getLexboxLogin(options) .then((request) => request(axios, basePath)); }, + /** + * + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + logOutLexbox(options?: any): AxiosPromise { + return localVarFp + .logOutLexbox(options) + .then((request) => request(axios, basePath)); + }, }; }; @@ -233,4 +298,16 @@ export class AuthApi extends BaseAPI { .getLexboxLogin(options) .then((request) => request(this.axios, this.basePath)); } + + /** + * + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof AuthApi + */ + public logOutLexbox(options?: any) { + return AuthApiFp(this.configuration) + .logOutLexbox(options) + .then((request) => request(this.axios, this.basePath)); + } } diff --git a/src/backend/index.ts b/src/backend/index.ts index 8fc1a394cd..924a31c781 100644 --- a/src/backend/index.ts +++ b/src/backend/index.ts @@ -192,7 +192,7 @@ export function getLexboxLoginUrl(): string { } export async function logoutLexboxUser(): Promise { - return; + await authApi.logOutLexbox(defaultOptions()); } /* AvatarController.cs */ diff --git a/src/components/Lexbox/LexboxLogin.tsx b/src/components/Lexbox/LexboxLogin.tsx index 27f13cd512..4872ca674e 100644 --- a/src/components/Lexbox/LexboxLogin.tsx +++ b/src/components/Lexbox/LexboxLogin.tsx @@ -25,7 +25,7 @@ interface LexboxLoginProps { export default function LexboxLogin(props: LexboxLoginProps): ReactElement { const { t } = useTranslation(); - const [status, setStatus] = useState(undefined); + const [status, setStatus] = useState(); const [statusLoading, setStatusLoading] = useState(true); const [actionLoading, setActionLoading] = useState(false); const [menuAnchor, setMenuAnchor] = useState(null); @@ -62,15 +62,11 @@ export default function LexboxLogin(props: LexboxLoginProps): ReactElement { } }; - const isLoggedIn = status?.isLoggedIn ?? false; - const menuOpen = Boolean(menuAnchor); - const label = status?.loggedInAs ?? t("login.login"); - - if (!isLoggedIn) { + if (!status?.isLoggedIn) { return ( {props.text ?? t("login.login")} @@ -80,21 +76,23 @@ export default function LexboxLogin(props: LexboxLoginProps): ReactElement { return ( <> + setMenuAnchor(null)} + open={Boolean(menuAnchor)} > - + + {t("userMenu.logout")} From 663fb0fc6d201a18cb8602c7a5005da01fd6e440 Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Tue, 24 Feb 2026 12:24:43 -0500 Subject: [PATCH 19/38] Fix tests --- src/components/ProjectScreen/tests/CreateProject.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/ProjectScreen/tests/CreateProject.test.tsx b/src/components/ProjectScreen/tests/CreateProject.test.tsx index 501d8551f4..2dc2a58795 100644 --- a/src/components/ProjectScreen/tests/CreateProject.test.tsx +++ b/src/components/ProjectScreen/tests/CreateProject.test.tsx @@ -30,7 +30,7 @@ jest.mock("components/LanguagePicker", () => ({ })); jest.mock("backend", () => ({ - getAuthStatus: jest.fn(), + getLexboxAuthStatus: jest.fn(), projectDuplicateCheck: () => mockProjectDuplicateCheck(), uploadLiftAndGetWritingSystems: () => mockUploadLiftAndGetWritingSystems(), })); From 2ca4dbf178c502f14e8bfa2cb5eaefe29d052d14 Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Mon, 2 Mar 2026 14:32:49 -0500 Subject: [PATCH 20/38] Update name and test --- .../Controllers/AuthControllerTests.cs | 14 +++++--- .../Mocks/AuthenticationServiceMock.cs | 7 +++- Backend/Controllers/AuthController.cs | 6 ++-- Backend/Startup.cs | 2 +- src/api/api/auth-api.ts | 36 +++++++++---------- 5 files changed, 38 insertions(+), 27 deletions(-) diff --git a/Backend.Tests/Controllers/AuthControllerTests.cs b/Backend.Tests/Controllers/AuthControllerTests.cs index c8a622dc8d..bc64b89ea1 100644 --- a/Backend.Tests/Controllers/AuthControllerTests.cs +++ b/Backend.Tests/Controllers/AuthControllerTests.cs @@ -80,13 +80,16 @@ public async Task GetAuthStatusReturnsLoggedOutWhenNotAuthenticatedByLexboxCooki } [Test] - public async Task GetLexboxLoginReturnsExpectedLoginPath() + public async Task GenerateLexboxLoginChallengesAndReturnsEmpty() { - _controller.ControllerContext.HttpContext = GetAuthContext(AuthenticateResult.NoResult()); + var authService = new AuthenticationServiceMock(AuthenticateResult.NoResult()); + _controller.ControllerContext.HttpContext = GetAuthContext(authService); + Assert.That(authService.ChallengeCallCount, Is.Zero); - var result = await _controller.GetLexboxLogin(); + var result = await _controller.GenerateLexboxLogin(); Assert.That(result, Is.InstanceOf()); + Assert.That(authService.ChallengeCallCount, Is.EqualTo(1)); } [Test] @@ -103,10 +106,13 @@ public async Task LogOutLexboxReturnsNoContent() } private static HttpContext GetAuthContext(AuthenticateResult authenticateResult) + => GetAuthContext(new AuthenticationServiceMock(authenticateResult)); + + private static HttpContext GetAuthContext(IAuthenticationService authenticationService) { var context = PermissionServiceMock.HttpContextWithUserId(UserId); var services = new ServiceCollection(); - services.AddSingleton(new AuthenticationServiceMock(authenticateResult)); + services.AddSingleton(authenticationService); context.RequestServices = services.BuildServiceProvider(); return context; } diff --git a/Backend.Tests/Mocks/AuthenticationServiceMock.cs b/Backend.Tests/Mocks/AuthenticationServiceMock.cs index 10754aeb71..061f78bf7e 100644 --- a/Backend.Tests/Mocks/AuthenticationServiceMock.cs +++ b/Backend.Tests/Mocks/AuthenticationServiceMock.cs @@ -7,11 +7,16 @@ namespace Backend.Tests.Mocks { internal sealed class AuthenticationServiceMock(AuthenticateResult authenticateResult) : IAuthenticationService { + internal int ChallengeCallCount { get; private set; } + public Task AuthenticateAsync(HttpContext context, string? scheme) => Task.FromResult(authenticateResult); public Task ChallengeAsync(HttpContext context, string? scheme, AuthenticationProperties? properties) - => Task.CompletedTask; + { + ChallengeCallCount++; + return Task.CompletedTask; + } public Task ForbidAsync(HttpContext context, string? scheme, AuthenticationProperties? properties) => Task.CompletedTask; diff --git a/Backend/Controllers/AuthController.cs b/Backend/Controllers/AuthController.cs index 4e8c4890a2..78f31b7a6b 100644 --- a/Backend/Controllers/AuthController.cs +++ b/Backend/Controllers/AuthController.cs @@ -47,12 +47,12 @@ public async Task GetAuthStatus() } /// Generates a redirect to Lexbox login for OIDC sign-in. - [HttpGet("lexbox-login", Name = "GetLexboxLogin")] + [HttpGet("lexbox-login", Name = "GenerateLexboxLogin")] [ProducesResponseType(StatusCodes.Status302Found)] [ProducesResponseType(StatusCodes.Status500InternalServerError)] - public async Task GetLexboxLogin() + public async Task GenerateLexboxLogin() { - using var activity = OtelService.StartActivityWithTag(otelTagName, "getting lexbox login"); + using var activity = OtelService.StartActivityWithTag(otelTagName, "generating Lexbox login"); var redirectUrl = NormalizeReturnUrl(_configuration[PostLoginRedirectConfigKey]) ?? NormalizeReturnUrl(Domain.FrontendDomain) diff --git a/Backend/Startup.cs b/Backend/Startup.cs index 88493cc731..36951b6afc 100644 --- a/Backend/Startup.cs +++ b/Backend/Startup.cs @@ -146,7 +146,7 @@ public void ConfigureServices(IServiceCollection services) 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"; + var lexboxScope = Configuration["LexboxAuth:Scope"] ?? "profile openid sendandreceive"; services.AddAuthentication(x => { diff --git a/src/api/api/auth-api.ts b/src/api/api/auth-api.ts index d8870384d8..a0a5fe8b09 100644 --- a/src/api/api/auth-api.ts +++ b/src/api/api/auth-api.ts @@ -51,8 +51,8 @@ export const AuthApiAxiosParamCreator = function ( * @param {*} [options] Override http request option. * @throws {RequiredError} */ - getAuthStatus: async (options: any = {}): Promise => { - const localVarPath = `/v1/auth/status`; + generateLexboxLogin: async (options: any = {}): Promise => { + const localVarPath = `/v1/auth/lexbox-login`; // use dummy base URL string because the URL constructor only accepts absolute URLs. const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); let baseOptions; @@ -87,8 +87,8 @@ export const AuthApiAxiosParamCreator = function ( * @param {*} [options] Override http request option. * @throws {RequiredError} */ - getLexboxLogin: async (options: any = {}): Promise => { - const localVarPath = `/v1/auth/lexbox-login`; + getAuthStatus: async (options: any = {}): Promise => { + const localVarPath = `/v1/auth/status`; // use dummy base URL string because the URL constructor only accepts absolute URLs. const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); let baseOptions; @@ -169,13 +169,13 @@ export const AuthApiFp = function (configuration?: Configuration) { * @param {*} [options] Override http request option. * @throws {RequiredError} */ - async getAuthStatus( + async generateLexboxLogin( options?: any ): Promise< - (axios?: AxiosInstance, basePath?: string) => AxiosPromise + (axios?: AxiosInstance, basePath?: string) => AxiosPromise > { const localVarAxiosArgs = - await localVarAxiosParamCreator.getAuthStatus(options); + await localVarAxiosParamCreator.generateLexboxLogin(options); return createRequestFunction( localVarAxiosArgs, globalAxios, @@ -188,13 +188,13 @@ export const AuthApiFp = function (configuration?: Configuration) { * @param {*} [options] Override http request option. * @throws {RequiredError} */ - async getLexboxLogin( + async getAuthStatus( options?: any ): Promise< - (axios?: AxiosInstance, basePath?: string) => AxiosPromise + (axios?: AxiosInstance, basePath?: string) => AxiosPromise > { const localVarAxiosArgs = - await localVarAxiosParamCreator.getLexboxLogin(options); + await localVarAxiosParamCreator.getAuthStatus(options); return createRequestFunction( localVarAxiosArgs, globalAxios, @@ -240,9 +240,9 @@ export const AuthApiFactory = function ( * @param {*} [options] Override http request option. * @throws {RequiredError} */ - getAuthStatus(options?: any): AxiosPromise { + generateLexboxLogin(options?: any): AxiosPromise { return localVarFp - .getAuthStatus(options) + .generateLexboxLogin(options) .then((request) => request(axios, basePath)); }, /** @@ -250,9 +250,9 @@ export const AuthApiFactory = function ( * @param {*} [options] Override http request option. * @throws {RequiredError} */ - getLexboxLogin(options?: any): AxiosPromise { + getAuthStatus(options?: any): AxiosPromise { return localVarFp - .getLexboxLogin(options) + .getAuthStatus(options) .then((request) => request(axios, basePath)); }, /** @@ -281,9 +281,9 @@ export class AuthApi extends BaseAPI { * @throws {RequiredError} * @memberof AuthApi */ - public getAuthStatus(options?: any) { + public generateLexboxLogin(options?: any) { return AuthApiFp(this.configuration) - .getAuthStatus(options) + .generateLexboxLogin(options) .then((request) => request(this.axios, this.basePath)); } @@ -293,9 +293,9 @@ export class AuthApi extends BaseAPI { * @throws {RequiredError} * @memberof AuthApi */ - public getLexboxLogin(options?: any) { + public getAuthStatus(options?: any) { return AuthApiFp(this.configuration) - .getLexboxLogin(options) + .getAuthStatus(options) .then((request) => request(this.axios, this.basePath)); } From 7eecbec60b95930ae0c93c3f1183dd2bb6850a30 Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Mon, 2 Mar 2026 14:46:45 -0500 Subject: [PATCH 21/38] Simplify config --- Backend/Startup.cs | 42 ++++++++++++---------------------------- Backend/appsettings.json | 15 ++++++++++++++ 2 files changed, 27 insertions(+), 30 deletions(-) diff --git a/Backend/Startup.cs b/Backend/Startup.cs index 36951b6afc..640ab6f6f6 100644 --- a/Backend/Startup.cs +++ b/Backend/Startup.cs @@ -134,19 +134,11 @@ 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; + var lexboxAuthConfig = Configuration.GetSection("LexboxAuth"); + // 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 sendandreceive"; + var lexboxAuthorizationEndpoint = lexboxAuthConfig["AuthorizationEndpoint"]?.Trim(); + var lexboxPrompt = lexboxAuthConfig["Prompt"]?.Trim(); services.AddAuthentication(x => { @@ -177,31 +169,21 @@ public void ConfigureServices(IServiceCollection services) }) .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); - } + lexboxAuthConfig.Bind(options); options.Events.OnRedirectToIdentityProvider = context => { - if (string.IsNullOrWhiteSpace(context.ProtocolMessage.IssuerAddress)) + if (string.IsNullOrWhiteSpace(context.ProtocolMessage.IssuerAddress) + && !string.IsNullOrEmpty(lexboxAuthorizationEndpoint)) { context.ProtocolMessage.IssuerAddress = lexboxAuthorizationEndpoint; } - context.ProtocolMessage.Prompt = lexboxPrompt; + if (!string.IsNullOrEmpty(lexboxPrompt)) + { + context.ProtocolMessage.Prompt = lexboxPrompt; + } + return System.Threading.Tasks.Task.CompletedTask; }; }); diff --git a/Backend/appsettings.json b/Backend/appsettings.json index f03b8c6e7f..18a687fcc1 100644 --- a/Backend/appsettings.json +++ b/Backend/appsettings.json @@ -1,4 +1,19 @@ { + "LexboxAuth": { + "AuthorizationEndpoint": "https://lexbox.org/api/oauth/open-id-auth", + "Prompt": "select_account", + "Authority": "https://lexbox.org", + "CallbackPath": "/v1/auth/oauth-callback", + "ClientId": "the-combine", + "MetadataAddress": "https://lexbox.org/.well-known/openid-configuration", + "GetClaimsFromUserInfoEndpoint": true, + "RequireHttpsMetadata": true, + "ResponseType": "code", + "SaveTokens": true, + "SignInScheme": "LexboxCookie", + "UsePkce": true, + "Scope": ["profile", "openid", "sendandreceive"] + }, "MongoDB": { "ConnectionString": "mongodb://localhost:27017", "ContainerConnectionString": "mongodb://database:27017", From bae8499ce0be0c43d12e954ff4633aacb79581ab Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Mon, 2 Mar 2026 15:47:09 -0500 Subject: [PATCH 22/38] Simplify Claims and align with LexCore/Auth/LexAuthConstants --- .../Controllers/AuthControllerTests.cs | 51 ++++++++++++++++++- Backend/Controllers/AuthController.cs | 27 ++++------ 2 files changed, 58 insertions(+), 20 deletions(-) diff --git a/Backend.Tests/Controllers/AuthControllerTests.cs b/Backend.Tests/Controllers/AuthControllerTests.cs index bc64b89ea1..4581726df1 100644 --- a/Backend.Tests/Controllers/AuthControllerTests.cs +++ b/Backend.Tests/Controllers/AuthControllerTests.cs @@ -49,7 +49,7 @@ public async Task GetAuthStatusUnauthorizedReturnsForbid() [Test] public async Task GetAuthStatusReturnsLexboxUserWhenLoggedIn() { - var claims = new List { new("sub", "lex-1"), new("preferred_username", "Lex User") }; + var claims = new List { new("sub", "lex-1"), new("name", "Lex Name"), new("user", "Lex User") }; var authResult = AuthenticateResult.Success(new AuthenticationTicket( new ClaimsPrincipal(new ClaimsIdentity(claims, "LexboxCookie")), "LexboxCookie")); _controller.ControllerContext.HttpContext = GetAuthContext(authResult); @@ -79,6 +79,53 @@ public async Task GetAuthStatusReturnsLoggedOutWhenNotAuthenticatedByLexboxCooki Assert.That(authStatus.UserId, Is.Null); } + [Test] + public void GetAuthStatusThrowsWhenSubClaimMissing() + { + var claims = new List { new("user", "Lex User") }; + var authResult = AuthenticateResult.Success(new AuthenticationTicket( + new ClaimsPrincipal(new ClaimsIdentity(claims, "LexboxCookie")), "LexboxCookie")); + _controller.ControllerContext.HttpContext = GetAuthContext(authResult); + + Assert.ThrowsAsync(_controller.GetAuthStatus); + } + + [Test] + public async Task GetAuthStatusFallsBackToUserIdWhenDisplayNameClaimsMissing() + { + var claims = new List { new("sub", "lex-1") }; + 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-1")); + Assert.That(authStatus.UserId, Is.EqualTo("lex-1")); + } + + [Test] + public async Task GetAuthStatusUsesNameClaimWhenUserClaimMissing() + { + var claims = new List { new("sub", "lex-1"), new("name", "Lex Name") }; + 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 Name")); + Assert.That(authStatus.UserId, Is.EqualTo("lex-1")); + } + [Test] public async Task GenerateLexboxLoginChallengesAndReturnsEmpty() { @@ -95,7 +142,7 @@ public async Task GenerateLexboxLoginChallengesAndReturnsEmpty() [Test] public async Task LogOutLexboxReturnsNoContent() { - var claims = new List { new("sub", "lex-1"), new("preferred_username", "Lex User") }; + var claims = new List { new("sub", "lex-1"), new("user", "Lex User") }; var authResult = AuthenticateResult.Success(new AuthenticationTicket( new ClaimsPrincipal(new ClaimsIdentity(claims, "LexboxCookie")), "LexboxCookie")); _controller.ControllerContext.HttpContext = GetAuthContext(authResult); diff --git a/Backend/Controllers/AuthController.cs b/Backend/Controllers/AuthController.cs index 78f31b7a6b..c7036e1a95 100644 --- a/Backend/Controllers/AuthController.cs +++ b/Backend/Controllers/AuthController.cs @@ -1,4 +1,5 @@ using System; +using System.Security.Claims; using System.Threading.Tasks; using BackendFramework.Helper; using BackendFramework.Interfaces; @@ -42,8 +43,7 @@ public async Task GetAuthStatus() return Ok(AuthStatus.LoggedOut()); } - var user = GetUserFromClaims(result.Principal); - return user is null ? Ok(AuthStatus.LoggedOut()) : Ok(AuthStatus.LoggedIn(user)); + return Ok(AuthStatus.LoggedIn(GetUserFromClaims(result.Principal))); } /// Generates a redirect to Lexbox login for OIDC sign-in. @@ -100,28 +100,19 @@ private async Task ChallengeLexboxAsync(AuthenticationProperties return uri.IsAbsoluteUri ? uri.PathAndQuery : uri.ToString(); } - private static LexboxAuthUser? GetUserFromClaims(System.Security.Claims.ClaimsPrincipal principal) + private static LexboxAuthUser GetUserFromClaims(ClaimsPrincipal principal) { - var userId = principal.FindFirst("sub")?.Value?.Trim(); + // https://github.com/sillsdev/languageforge-lexbox/blob/develop/backend/LexCore/Auth/LexAuthConstants.cs + var userId = principal.FindFirst("sub")?.Value?.Trim(); // LexAuthConstants.IdClaimType if (string.IsNullOrEmpty(userId)) { - userId = principal.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value?.Trim(); + throw new InvalidOperationException("Missing required Lexbox 'sub' claim."); } - 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; - } + var displayName = principal.FindFirst("user")?.Value // LexAuthConstants.UsernameClaimType + ?? principal.FindFirst("name")?.Value; // LexAuthConstants.NameClaimType - return string.IsNullOrEmpty(displayName) && string.IsNullOrEmpty(userId) - ? null - : new LexboxAuthUser { DisplayName = displayName, UserId = userId }; + return new LexboxAuthUser { DisplayName = displayName ?? userId, UserId = userId }; } } } From 8956f133a6137e53a1395341a8fbf98b8043f119 Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Mon, 2 Mar 2026 15:49:30 -0500 Subject: [PATCH 23/38] Alphabetize --- Backend/appsettings.json | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/Backend/appsettings.json b/Backend/appsettings.json index 18a687fcc1..d5d0cec8ea 100644 --- a/Backend/appsettings.json +++ b/Backend/appsettings.json @@ -1,18 +1,18 @@ { "LexboxAuth": { - "AuthorizationEndpoint": "https://lexbox.org/api/oauth/open-id-auth", - "Prompt": "select_account", "Authority": "https://lexbox.org", + "AuthorizationEndpoint": "https://lexbox.org/api/oauth/open-id-auth", "CallbackPath": "/v1/auth/oauth-callback", "ClientId": "the-combine", - "MetadataAddress": "https://lexbox.org/.well-known/openid-configuration", "GetClaimsFromUserInfoEndpoint": true, + "MetadataAddress": "https://lexbox.org/.well-known/openid-configuration", + "Prompt": "select_account", "RequireHttpsMetadata": true, "ResponseType": "code", "SaveTokens": true, + "Scope": ["openid", "profile", "sendandreceive"], "SignInScheme": "LexboxCookie", - "UsePkce": true, - "Scope": ["profile", "openid", "sendandreceive"] + "UsePkce": true }, "MongoDB": { "ConnectionString": "mongodb://localhost:27017", From fe241dd947b234ed665bbfd43e9865e18ac59b5a Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Mon, 2 Mar 2026 16:37:59 -0500 Subject: [PATCH 24/38] Pick some bunny nits --- Backend/Controllers/AuthController.cs | 2 +- Backend/Models/LexboxAuthUser.cs | 4 ++-- src/api/api/auth-api.ts | 2 +- src/components/Lexbox/LexboxLogin.tsx | 7 +++++-- 4 files changed, 9 insertions(+), 6 deletions(-) diff --git a/Backend/Controllers/AuthController.cs b/Backend/Controllers/AuthController.cs index c7036e1a95..28adb48089 100644 --- a/Backend/Controllers/AuthController.cs +++ b/Backend/Controllers/AuthController.cs @@ -63,7 +63,7 @@ public async Task GenerateLexboxLogin() } /// Signs out the current user from Lexbox cookie and OIDC. - [HttpGet("lexbox-logout", Name = "LogOutLexbox")] + [HttpPost("lexbox-logout", Name = "LogOutLexbox")] [ProducesResponseType(StatusCodes.Status204NoContent)] public async Task LogOutLexbox() { diff --git a/Backend/Models/LexboxAuthUser.cs b/Backend/Models/LexboxAuthUser.cs index 47e91b5db3..9fb5356a53 100644 --- a/Backend/Models/LexboxAuthUser.cs +++ b/Backend/Models/LexboxAuthUser.cs @@ -2,7 +2,7 @@ namespace BackendFramework.Models { public class LexboxAuthUser { - public string? UserId { get; init; } - public string? DisplayName { get; init; } + public required string UserId { get; init; } + public required string DisplayName { get; init; } } } diff --git a/src/api/api/auth-api.ts b/src/api/api/auth-api.ts index a0a5fe8b09..b8747ce31b 100644 --- a/src/api/api/auth-api.ts +++ b/src/api/api/auth-api.ts @@ -133,7 +133,7 @@ export const AuthApiAxiosParamCreator = function ( } const localVarRequestOptions = { - method: "GET", + method: "POST", ...baseOptions, ...options, }; diff --git a/src/components/Lexbox/LexboxLogin.tsx b/src/components/Lexbox/LexboxLogin.tsx index 4872ca674e..ed69e9669b 100644 --- a/src/components/Lexbox/LexboxLogin.tsx +++ b/src/components/Lexbox/LexboxLogin.tsx @@ -9,6 +9,7 @@ import { } from "@mui/material"; import { ReactElement, useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; +import { toast } from "react-toastify"; import { type AuthStatus } from "api/models"; import { @@ -46,8 +47,10 @@ export default function LexboxLogin(props: LexboxLoginProps): ReactElement { loadStatus(); }, []); - const handleLogin = async (): Promise => { - window.open(getLexboxLoginUrl()); + const handleLogin = (): void => { + if (!window.open(getLexboxLoginUrl())) { + toast.error("Failed to open login window"); + } }; const handleLogout = async (): Promise => { From a0a137f41cab353d7ba8b26fd15e05013576dff1 Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Wed, 25 Mar 2026 17:32:56 -0400 Subject: [PATCH 25/38] Handle a couple Devin comments --- Backend/Controllers/AuthController.cs | 5 ++++- src/backend/index.ts | 1 + 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/Backend/Controllers/AuthController.cs b/Backend/Controllers/AuthController.cs index 28adb48089..6e4a9b10a9 100644 --- a/Backend/Controllers/AuthController.cs +++ b/Backend/Controllers/AuthController.cs @@ -70,7 +70,10 @@ public async Task LogOutLexbox() using var activity = OtelService.StartActivityWithTag(otelTagName, "logging out"); await HttpContext.SignOutAsync(LexboxCookieScheme); - await HttpContext.SignOutAsync(LexboxOidcScheme); + + // TODO: Consider if we also need to sign out of the OIDC scheme here. + // await HttpContext.SignOutAsync(LexboxOidcScheme) + // is a no-op since it doesn't handle the redirect. return NoContent(); } diff --git a/src/backend/index.ts b/src/backend/index.ts index 6ab7ee5ebf..a9dc6f8d78 100644 --- a/src/backend/index.ts +++ b/src/backend/index.ts @@ -57,6 +57,7 @@ const authenticationUrls = [ /** A list of URL patterns for which the frontend explicitly handles errors * and the blanket error pop-ups should be suppressed.*/ const whiteListedErrorUrls = [ + "/auth/status", "/merge/retrievedups", "/speakers/create", "/speakers/update/", From d49116281bf1870f1773d40e47dcceee9f118606 Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Thu, 26 Mar 2026 13:13:45 -0400 Subject: [PATCH 26/38] Fix auth and callback in dev --- Backend/Controllers/AuthController.cs | 12 ++++-------- Backend/Startup.cs | 15 ++++++++++++++- Backend/appsettings.json | 1 - 3 files changed, 18 insertions(+), 10 deletions(-) diff --git a/Backend/Controllers/AuthController.cs b/Backend/Controllers/AuthController.cs index 6e4a9b10a9..9d3766e640 100644 --- a/Backend/Controllers/AuthController.cs +++ b/Backend/Controllers/AuthController.cs @@ -55,8 +55,7 @@ public async Task GenerateLexboxLogin() using var activity = OtelService.StartActivityWithTag(otelTagName, "generating Lexbox login"); var redirectUrl = NormalizeReturnUrl(_configuration[PostLoginRedirectConfigKey]) - ?? NormalizeReturnUrl(Domain.FrontendDomain) - ?? "/"; + ?? Domain.FrontendDomain + "/app/auth-success"; var authProperties = new AuthenticationProperties { RedirectUri = redirectUrl }; return await ChallengeLexboxAsync(authProperties); @@ -95,12 +94,9 @@ private async Task ChallengeLexboxAsync(AuthenticationProperties 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(); + return string.IsNullOrEmpty(url) || !Uri.TryCreate(url, UriKind.RelativeOrAbsolute, out var uri) + ? null + : uri.ToString(); } private static LexboxAuthUser GetUserFromClaims(ClaimsPrincipal principal) diff --git a/Backend/Startup.cs b/Backend/Startup.cs index 4f2fc1d1c3..bb7b85d6c9 100644 --- a/Backend/Startup.cs +++ b/Backend/Startup.cs @@ -1,5 +1,7 @@ using System; using System.Diagnostics.CodeAnalysis; +using System.Linq; +using System.Net.Http; using System.Text.Json.Serialization; using BackendFramework.Contexts; using BackendFramework.Helper; @@ -136,7 +138,7 @@ public void ConfigureServices(IServiceCollection services) var lexboxAuthConfig = Configuration.GetSection("LexboxAuth"); - // Authorization endpoint needs to be defined before discovery happens with the metadata address. + // Authorization endpoint needs to be defined because discovery silently fails in dev. var lexboxAuthorizationEndpoint = lexboxAuthConfig["AuthorizationEndpoint"]?.Trim(); var lexboxPrompt = lexboxAuthConfig["Prompt"]?.Trim(); @@ -171,6 +173,17 @@ public void ConfigureServices(IServiceCollection services) { lexboxAuthConfig.Bind(options); + // Discovery isn't working in dev, so manually fetch the keys. + options.TokenValidationParameters.IssuerSigningKeyResolver = (_, _, kid, _) => + { + // Use a simple HttpClient to fetch the keys. + // In a real app, you'd cache these for 24 hours. + var client = new HttpClient(); + var response = client.GetStringAsync("https://lexbox.org/.well-known/jwks").Result; + var keys = new JsonWebKeySet(response).GetSigningKeys(); + return keys.Where(x => x.KeyId == kid); + }; + options.Events.OnRedirectToIdentityProvider = context => { if (string.IsNullOrWhiteSpace(context.ProtocolMessage.IssuerAddress) diff --git a/Backend/appsettings.json b/Backend/appsettings.json index 20ef702f4f..c6b5feacf1 100644 --- a/Backend/appsettings.json +++ b/Backend/appsettings.json @@ -5,7 +5,6 @@ "CallbackPath": "/v1/auth/oauth-callback", "ClientId": "the-combine", "GetClaimsFromUserInfoEndpoint": true, - "MetadataAddress": "https://lexbox.org/.well-known/openid-configuration", "Prompt": "select_account", "RequireHttpsMetadata": true, "ResponseType": "code", From 43476ed66985e0f230466156d19add4ad2407f70 Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Mon, 6 Apr 2026 14:57:44 -0400 Subject: [PATCH 27/38] Fix authentication handling --- Backend/Controllers/AuthController.cs | 5 +++++ Backend/Startup.cs | 31 +++++++++++++++++++++------ Backend/appsettings.Development.json | 3 +++ src/backend/index.ts | 15 ++++++------- src/components/App/SignalRHub.tsx | 6 ++++-- 5 files changed, 44 insertions(+), 16 deletions(-) diff --git a/Backend/Controllers/AuthController.cs b/Backend/Controllers/AuthController.cs index 9d3766e640..eee00f6423 100644 --- a/Backend/Controllers/AuthController.cs +++ b/Backend/Controllers/AuthController.cs @@ -40,6 +40,11 @@ public async Task GetAuthStatus() var result = await HttpContext.AuthenticateAsync(LexboxCookieScheme); if (!result.Succeeded || result.Principal is null) { + // Clear any stale or undecryptable cookie (e.g. after a server restart loses Data Protection keys) + if (HttpContext.Request.Cookies.ContainsKey("lexbox_auth")) + { + await HttpContext.SignOutAsync(LexboxCookieScheme); + } return Ok(AuthStatus.LoggedOut()); } diff --git a/Backend/Startup.cs b/Backend/Startup.cs index bb7b85d6c9..7403acaf53 100644 --- a/Backend/Startup.cs +++ b/Backend/Startup.cs @@ -3,6 +3,7 @@ using System.Linq; using System.Net.Http; using System.Text.Json.Serialization; +using System.Threading.Tasks; using BackendFramework.Contexts; using BackendFramework.Helper; using BackendFramework.Interfaces; @@ -173,13 +174,17 @@ public void ConfigureServices(IServiceCollection services) { lexboxAuthConfig.Bind(options); - // Discovery isn't working in dev, so manually fetch the keys. + // Keep claim names (e.g. "sub", "name") as-is, rather than remapping to URI-based ClaimTypes. + options.MapInboundClaims = false; + + // Discovery isn't working (at least in dev), so manually fetch the keys. options.TokenValidationParameters.IssuerSigningKeyResolver = (_, _, kid, _) => { - // Use a simple HttpClient to fetch the keys. - // In a real app, you'd cache these for 24 hours. - var client = new HttpClient(); - var response = client.GetStringAsync("https://lexbox.org/.well-known/jwks").Result; + var jwksUrl = "https://lexbox.org/.well-known/jwks"; + // Task.Run avoids sync-over-async deadlock by running on a thread-pool thread with no + // synchronization context. + var response = Task.Run(() => new HttpClient().GetStringAsync(jwksUrl)) + .GetAwaiter().GetResult(); var keys = new JsonWebKeySet(response).GetSigningKeys(); return keys.Where(x => x.KeyId == kid); }; @@ -197,7 +202,21 @@ public void ConfigureServices(IServiceCollection services) context.ProtocolMessage.Prompt = lexboxPrompt; } - return System.Threading.Tasks.Task.CompletedTask; + return Task.CompletedTask; + }; + + options.Events.OnRemoteFailure = ctx => + { + _logger.LogError(ctx.Failure, "[OIDC] Remote failure: {Message}", ctx.Failure?.Message); + ctx.HandleResponse(); + ctx.Response.Redirect("/error"); + return Task.CompletedTask; + }; + + options.Events.OnAuthenticationFailed = ctx => + { + _logger.LogError(ctx.Exception, "[OIDC] Authentication failed: {Message}", ctx.Exception?.Message); + return Task.CompletedTask; }; }); diff --git a/Backend/appsettings.Development.json b/Backend/appsettings.Development.json index e203e9407e..31223bf7ef 100644 --- a/Backend/appsettings.Development.json +++ b/Backend/appsettings.Development.json @@ -1,4 +1,7 @@ { + "LexboxAuth": { + "GetClaimsFromUserInfoEndpoint": false + }, "Logging": { "LogLevel": { "Default": "Debug", diff --git a/src/backend/index.ts b/src/backend/index.ts index a9dc6f8d78..e3fabc4f30 100644 --- a/src/backend/index.ts +++ b/src/backend/index.ts @@ -40,10 +40,8 @@ import { FileWithSpeakerId } from "types/word"; import { Bcp47Code } from "types/writingSystem"; import { convertGoalToEdit } from "utilities/goalUtilities"; -export const baseURL = `${RuntimeConfig.getInstance().baseUrl()}`; -const apiBaseURL = `${baseURL}/v1`; -const config_parameters: Api.ConfigurationParameters = { basePath: baseURL }; -const config = new Api.Configuration(config_parameters); +const basePath = RuntimeConfig.getInstance().baseUrl(); +const config = new Api.Configuration({ basePath }); /** A list of URL patterns for which user analytics should not be collected. */ const authenticationUrls = [ @@ -67,7 +65,8 @@ const whiteListedErrorUrls = [ ]; // Create an axios instance to allow for attaching interceptors to it. -const axiosInstance = axios.create({ baseURL: apiBaseURL }); +const baseURL = `${basePath}/v1`; +const axiosInstance = axios.create({ baseURL, withCredentials: true }); axiosInstance.interceptors.request.use((config: InternalAxiosRequestConfig) => { const consent = LocalStorage.getCurrentUser()?.analyticsOn; const url = config.url; @@ -179,7 +178,7 @@ export async function deleteAudio( * Note: Backend doesn't need wordId to find the file, * but it's still required in the url and helpful for analytics. */ export function getAudioUrl(wordId: string, fileName: string): string { - return `${apiBaseURL}/projects/${LocalStorage.getProjectId()}/words/${wordId}/audio/download/${fileName}`; + return `${baseURL}/projects/${LocalStorage.getProjectId()}/words/${wordId}/audio/download/${fileName}`; } /* AuthController.cs */ @@ -189,7 +188,7 @@ export async function getLexboxAuthStatus(): Promise { } export function getLexboxLoginUrl(): string { - return `${baseURL}/v1/auth/lexbox-login`; + return `${baseURL}/auth/lexbox-login`; } export async function logoutLexboxUser(): Promise { @@ -657,7 +656,7 @@ export async function uploadConsent( /** Use of the returned url acts as an HttpGet. */ export function getConsentUrl(speaker: Speaker): string { - return `${apiBaseURL}/projects/${speaker.projectId}/speakers/consent/${speaker.id}`; + return `${baseURL}/projects/${speaker.projectId}/speakers/consent/${speaker.id}`; } /** Returns the string to display the image inline in Base64 ; @@ -36,6 +36,8 @@ const failureMethodName = "Failure"; /** Matches `CombineHub.MethodSuccess` in Backend/Helper/CombineHub.cs */ const successMethodName = "Success"; +const baseUrl = RuntimeConfig.getInstance().baseUrl(); + /** A central hub for monitoring export status on SignalR */ export default function SignalRHub(props: SignalRHubProps): ReactElement { const { connect, failureAction, successAction, url } = props; @@ -66,7 +68,7 @@ export default function SignalRHub(props: SignalRHubProps): ReactElement { useEffect(() => { if (!disconnect && reconnect) { const newConnection = new HubConnectionBuilder() - .withUrl(`${baseURL}/${url}`) + .withUrl(`${baseUrl}/${url}`) .withAutomaticReconnect() .build(); setReconnect(false); From f01e979fc99aea528e6c04681f4964ca46eb9a5b Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Mon, 6 Apr 2026 16:19:04 -0400 Subject: [PATCH 28/38] Add project-fetching --- Backend/Controllers/AuthController.cs | 162 +++++++++++++++++- src/api/.openapi-generator/FILES | 4 + src/api/api/auth-api.ts | 82 +++++++++ src/api/models/flex-project-metadata-dto.ts | 53 ++++++ src/api/models/flex-ws-id-dto.ts | 39 +++++ src/api/models/index.ts | 4 + src/api/models/lexbox-project.ts | 101 +++++++++++ src/api/models/project-writing-systems-dto.ts | 35 ++++ 8 files changed, 479 insertions(+), 1 deletion(-) create mode 100644 src/api/models/flex-project-metadata-dto.ts create mode 100644 src/api/models/flex-ws-id-dto.ts create mode 100644 src/api/models/lexbox-project.ts create mode 100644 src/api/models/project-writing-systems-dto.ts diff --git a/Backend/Controllers/AuthController.cs b/Backend/Controllers/AuthController.cs index eee00f6423..b07578e1e8 100644 --- a/Backend/Controllers/AuthController.cs +++ b/Backend/Controllers/AuthController.cs @@ -1,10 +1,16 @@ using System; +using System.Collections.Generic; +using System.Linq; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Net.Http.Json; using System.Security.Claims; using System.Threading.Tasks; using BackendFramework.Helper; using BackendFramework.Interfaces; using BackendFramework.Models; using BackendFramework.Otel; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; @@ -14,15 +20,52 @@ namespace BackendFramework.Controllers { [Produces("application/json")] [Route("v1/auth")] - public class AuthController(IConfiguration configuration, IPermissionService permissionService) : Controller + public class AuthController(IConfiguration configuration, IPermissionService permissionService, + IHttpClientFactory httpClientFactory) : Controller { private readonly IConfiguration _configuration = configuration; private readonly IPermissionService _permissionService = permissionService; + private readonly IHttpClientFactory _httpClientFactory = httpClientFactory; private const string otelTagName = "otel.AuthController"; private const string LexboxCookieScheme = "LexboxCookie"; private const string LexboxOidcScheme = "LexboxOidc"; private const string PostLoginRedirectConfigKey = "LexboxAuth:PostLoginRedirect"; + private const string LexboxGraphQlUrl = "https://lexbox.org/api/graphql"; + private const string LexboxMyProjectsQuery = @"query { + myProjects { + id + parentId + code + name + description + retentionPolicy + type + isConfidential + repoSizeInKb + resetStatus + projectOrigin + userCount + flexProjectMetadata { + projectId + lexEntryCount + langProjectId + flexModelVersion + writingSystems { + vernacularWss { + tag + isActive + isDefault + } + analysisWss { + tag + isActive + isDefault + } + } + } + } +}"; /// Gets authentication status for the current request. [HttpGet("status", Name = "GetAuthStatus")] @@ -82,6 +125,63 @@ public async Task LogOutLexbox() return NoContent(); } + /// Gets Lexbox projects for the signed-in Lexbox user. + [Authorize(AuthenticationSchemes = LexboxCookieScheme)] + [HttpGet("lexbox-projects", Name = "GetLexboxProjects")] + [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(LexboxProject[]))] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status502BadGateway)] + public async Task GetLexboxProjects() + { + using var activity = OtelService.StartActivityWithTag(otelTagName, "getting Lexbox projects"); + + var accessToken = await TryGetLexboxAccessTokenAsync(); + if (string.IsNullOrEmpty(accessToken)) + { + return Unauthorized(); + } + + var httpClient = _httpClientFactory.CreateClient(); + httpClient.DefaultRequestHeaders.Authorization = + new AuthenticationHeaderValue("Bearer", accessToken); + + var response = await httpClient.PostAsJsonAsync(LexboxGraphQlUrl, + new GraphQlQuery { Query = LexboxMyProjectsQuery }); + + if (!response.IsSuccessStatusCode) + { + return Problem( + title: "Lexbox GraphQL request failed", + detail: $"Status: {(int)response.StatusCode} {response.ReasonPhrase}", + statusCode: StatusCodes.Status502BadGateway); + } + + var graph = await response.Content.ReadFromJsonAsync>(); + if (graph is null) + { + return Problem( + title: "Lexbox GraphQL response was empty", + statusCode: StatusCodes.Status502BadGateway); + } + + if (graph.Errors is { Length: > 0 }) + { + var errorText = string.Join("; ", graph.Errors.Select(e => e.Message).Where(m => !string.IsNullOrEmpty(m))); + return Problem( + title: "Lexbox GraphQL returned errors", + detail: errorText, + statusCode: StatusCodes.Status502BadGateway); + } + + return Ok(graph.Data?.MyProjects ?? []); + } + + private async Task TryGetLexboxAccessTokenAsync() + { + var result = await HttpContext.AuthenticateAsync(LexboxCookieScheme); + return result.Properties?.GetTokenValue("access_token"); + } + private async Task ChallengeLexboxAsync(AuthenticationProperties authProperties) { try @@ -118,5 +218,65 @@ private static LexboxAuthUser GetUserFromClaims(ClaimsPrincipal principal) return new LexboxAuthUser { DisplayName = displayName ?? userId, UserId = userId }; } + + private sealed class GraphQlQuery + { + public required string Query { get; init; } + } + + private sealed class GraphQlResponse + { + public T? Data { get; init; } + public GraphQlError[]? Errors { get; init; } + } + + private sealed class GraphQlError + { + public string? Message { get; init; } + } + + private sealed class LexboxMyProjectsData + { + public List MyProjects { get; init; } = []; + } + + public sealed class LexboxProject + { + public Guid Id { get; init; } + public Guid? ParentId { get; init; } + public string Code { get; init; } = ""; + public string Name { get; init; } = ""; + public string? Description { get; init; } + public string RetentionPolicy { get; init; } = ""; + public string Type { get; init; } = ""; + public bool? IsConfidential { get; init; } + public int? RepoSizeInKb { get; init; } + public string ResetStatus { get; init; } = ""; + public string ProjectOrigin { get; init; } = ""; + public int UserCount { get; init; } + public FlexProjectMetadataDto? FlexProjectMetadata { get; init; } + } + + public sealed class FlexProjectMetadataDto + { + public Guid ProjectId { get; init; } + public int? LexEntryCount { get; init; } + public Guid? LangProjectId { get; init; } + public int? FlexModelVersion { get; init; } + public ProjectWritingSystemsDto? WritingSystems { get; init; } + } + + public sealed class ProjectWritingSystemsDto + { + public List VernacularWss { get; init; } = []; + public List AnalysisWss { get; init; } = []; + } + + public sealed class FLExWsIdDto + { + public string Tag { get; init; } = ""; + public bool IsActive { get; init; } + public bool IsDefault { get; init; } + } } } diff --git a/src/api/.openapi-generator/FILES b/src/api/.openapi-generator/FILES index 867c9d7e0d..ccad5a79c2 100644 --- a/src/api/.openapi-generator/FILES +++ b/src/api/.openapi-generator/FILES @@ -36,10 +36,13 @@ models/edit.ts models/email-invite-data.ts models/email-invite-status.ts models/flag.ts +models/flex-project-metadata-dto.ts +models/flex-ws-id-dto.ts models/gloss.ts models/gram-cat-group.ts models/grammatical-info.ts models/index.ts +models/lexbox-project.ts models/merge-source-word.ts models/merge-undo-ids.ts models/merge-words.ts @@ -48,6 +51,7 @@ models/off-on-setting.ts models/password-reset-data.ts models/permission.ts models/project-role.ts +models/project-writing-systems-dto.ts models/project.ts models/pronunciation.ts models/protect-reason.ts diff --git a/src/api/api/auth-api.ts b/src/api/api/auth-api.ts index b8747ce31b..9f6c32b9fc 100644 --- a/src/api/api/auth-api.ts +++ b/src/api/api/auth-api.ts @@ -38,6 +38,8 @@ import { } from "../base"; // @ts-ignore import { AuthStatus } from "../models"; +// @ts-ignore +import { LexboxProject } from "../models"; /** * AuthApi - axios parameter creator * @export @@ -118,6 +120,42 @@ export const AuthApiAxiosParamCreator = function ( options: localVarRequestOptions, }; }, + /** + * + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + getLexboxProjects: async (options: any = {}): Promise => { + const localVarPath = `/v1/auth/lexbox-projects`; + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { + method: "GET", + ...baseOptions, + ...options, + }; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + setSearchParams(localVarUrlObj, localVarQueryParameter, options.query); + let headersFromBaseOptions = + baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = { + ...localVarHeaderParameter, + ...headersFromBaseOptions, + ...options.headers, + }; + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, /** * * @param {*} [options] Override http request option. @@ -202,6 +240,28 @@ export const AuthApiFp = function (configuration?: Configuration) { configuration ); }, + /** + * + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async getLexboxProjects( + options?: any + ): Promise< + ( + axios?: AxiosInstance, + basePath?: string + ) => AxiosPromise> + > { + const localVarAxiosArgs = + await localVarAxiosParamCreator.getLexboxProjects(options); + return createRequestFunction( + localVarAxiosArgs, + globalAxios, + BASE_PATH, + configuration + ); + }, /** * * @param {*} [options] Override http request option. @@ -255,6 +315,16 @@ export const AuthApiFactory = function ( .getAuthStatus(options) .then((request) => request(axios, basePath)); }, + /** + * + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + getLexboxProjects(options?: any): AxiosPromise> { + return localVarFp + .getLexboxProjects(options) + .then((request) => request(axios, basePath)); + }, /** * * @param {*} [options] Override http request option. @@ -299,6 +369,18 @@ export class AuthApi extends BaseAPI { .then((request) => request(this.axios, this.basePath)); } + /** + * + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof AuthApi + */ + public getLexboxProjects(options?: any) { + return AuthApiFp(this.configuration) + .getLexboxProjects(options) + .then((request) => request(this.axios, this.basePath)); + } + /** * * @param {*} [options] Override http request option. diff --git a/src/api/models/flex-project-metadata-dto.ts b/src/api/models/flex-project-metadata-dto.ts new file mode 100644 index 0000000000..14462ff152 --- /dev/null +++ b/src/api/models/flex-project-metadata-dto.ts @@ -0,0 +1,53 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * BackendFramework + * No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) + * + * The version of the OpenAPI document: 1.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + +import { ProjectWritingSystemsDto } from "./project-writing-systems-dto"; + +/** + * + * @export + * @interface FlexProjectMetadataDto + */ +export interface FlexProjectMetadataDto { + /** + * + * @type {string} + * @memberof FlexProjectMetadataDto + */ + projectId?: string; + /** + * + * @type {number} + * @memberof FlexProjectMetadataDto + */ + lexEntryCount?: number | null; + /** + * + * @type {string} + * @memberof FlexProjectMetadataDto + */ + langProjectId?: string | null; + /** + * + * @type {number} + * @memberof FlexProjectMetadataDto + */ + flexModelVersion?: number | null; + /** + * + * @type {ProjectWritingSystemsDto} + * @memberof FlexProjectMetadataDto + */ + writingSystems?: ProjectWritingSystemsDto; +} diff --git a/src/api/models/flex-ws-id-dto.ts b/src/api/models/flex-ws-id-dto.ts new file mode 100644 index 0000000000..d130031274 --- /dev/null +++ b/src/api/models/flex-ws-id-dto.ts @@ -0,0 +1,39 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * BackendFramework + * No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) + * + * The version of the OpenAPI document: 1.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + +/** + * + * @export + * @interface FLExWsIdDto + */ +export interface FLExWsIdDto { + /** + * + * @type {string} + * @memberof FLExWsIdDto + */ + tag?: string | null; + /** + * + * @type {boolean} + * @memberof FLExWsIdDto + */ + isActive?: boolean; + /** + * + * @type {boolean} + * @memberof FLExWsIdDto + */ + isDefault?: boolean; +} diff --git a/src/api/models/index.ts b/src/api/models/index.ts index 0797af80a6..c0810553fa 100644 --- a/src/api/models/index.ts +++ b/src/api/models/index.ts @@ -9,10 +9,13 @@ export * from "./definition"; export * from "./edit"; export * from "./email-invite-data"; export * from "./email-invite-status"; +export * from "./flex-ws-id-dto"; export * from "./flag"; +export * from "./flex-project-metadata-dto"; export * from "./gloss"; export * from "./gram-cat-group"; export * from "./grammatical-info"; +export * from "./lexbox-project"; export * from "./merge-source-word"; export * from "./merge-undo-ids"; export * from "./merge-words"; @@ -22,6 +25,7 @@ export * from "./password-reset-data"; export * from "./permission"; export * from "./project"; export * from "./project-role"; +export * from "./project-writing-systems-dto"; export * from "./pronunciation"; export * from "./protect-reason"; export * from "./reason-type"; diff --git a/src/api/models/lexbox-project.ts b/src/api/models/lexbox-project.ts new file mode 100644 index 0000000000..28f9cc2b3d --- /dev/null +++ b/src/api/models/lexbox-project.ts @@ -0,0 +1,101 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * BackendFramework + * No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) + * + * The version of the OpenAPI document: 1.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + +import { FlexProjectMetadataDto } from "./flex-project-metadata-dto"; + +/** + * + * @export + * @interface LexboxProject + */ +export interface LexboxProject { + /** + * + * @type {string} + * @memberof LexboxProject + */ + id?: string; + /** + * + * @type {string} + * @memberof LexboxProject + */ + parentId?: string | null; + /** + * + * @type {string} + * @memberof LexboxProject + */ + code?: string | null; + /** + * + * @type {string} + * @memberof LexboxProject + */ + name?: string | null; + /** + * + * @type {string} + * @memberof LexboxProject + */ + description?: string | null; + /** + * + * @type {string} + * @memberof LexboxProject + */ + retentionPolicy?: string | null; + /** + * + * @type {string} + * @memberof LexboxProject + */ + type?: string | null; + /** + * + * @type {boolean} + * @memberof LexboxProject + */ + isConfidential?: boolean | null; + /** + * + * @type {number} + * @memberof LexboxProject + */ + repoSizeInKb?: number | null; + /** + * + * @type {string} + * @memberof LexboxProject + */ + resetStatus?: string | null; + /** + * + * @type {string} + * @memberof LexboxProject + */ + projectOrigin?: string | null; + /** + * + * @type {number} + * @memberof LexboxProject + */ + userCount?: number; + /** + * + * @type {FlexProjectMetadataDto} + * @memberof LexboxProject + */ + flexProjectMetadata?: FlexProjectMetadataDto; +} diff --git a/src/api/models/project-writing-systems-dto.ts b/src/api/models/project-writing-systems-dto.ts new file mode 100644 index 0000000000..1f2824cdd3 --- /dev/null +++ b/src/api/models/project-writing-systems-dto.ts @@ -0,0 +1,35 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * BackendFramework + * No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) + * + * The version of the OpenAPI document: 1.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + +import { FLExWsIdDto } from "./flex-ws-id-dto"; + +/** + * + * @export + * @interface ProjectWritingSystemsDto + */ +export interface ProjectWritingSystemsDto { + /** + * + * @type {Array} + * @memberof ProjectWritingSystemsDto + */ + vernacularWss?: Array | null; + /** + * + * @type {Array} + * @memberof ProjectWritingSystemsDto + */ + analysisWss?: Array | null; +} From cb5cf0c45a4b73315556943d5c75d3089d2767a7 Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Tue, 7 Apr 2026 12:00:51 -0400 Subject: [PATCH 29/38] Complete the chain --- Backend.Tests/Controllers/AuthControllerTests.cs | 15 ++++++++++----- Backend/Controllers/AuthController.cs | 12 ++++++------ .../{AuthStatus.cs => LexboxAuthStatus.cs} | 12 +++++++++--- Backend/Models/LexboxAuthUser.cs | 8 -------- src/api/.openapi-generator/FILES | 2 +- src/api/api/auth-api.ts | 9 ++++++--- src/api/models/index.ts | 2 +- .../{auth-status.ts => lexbox-auth-status.ts} | 10 +++++----- src/backend/index.ts | 9 +++++++-- src/components/Lexbox/LexboxLogin.tsx | 16 ++++++++++++---- 10 files changed, 57 insertions(+), 38 deletions(-) rename Backend/Models/{AuthStatus.cs => LexboxAuthStatus.cs} (52%) delete mode 100644 Backend/Models/LexboxAuthUser.cs rename src/api/models/{auth-status.ts => lexbox-auth-status.ts} (78%) diff --git a/Backend.Tests/Controllers/AuthControllerTests.cs b/Backend.Tests/Controllers/AuthControllerTests.cs index 4581726df1..639301dc8b 100644 --- a/Backend.Tests/Controllers/AuthControllerTests.cs +++ b/Backend.Tests/Controllers/AuthControllerTests.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Net.Http; using System.Security.Claims; using System.Threading.Tasks; using Backend.Tests.Mocks; @@ -10,6 +11,7 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; +using Moq; using NUnit.Framework; namespace Backend.Tests.Controllers @@ -32,8 +34,11 @@ public void Setup() { var configValues = new Dictionary { { "LexboxAuth:PostLoginRedirect", "/" } }; var configuration = new ConfigurationBuilder().AddInMemoryCollection(configValues).Build(); + var httpClient = new HttpClient(new Mock().Object); + var httpClientFactory = new Mock(); + httpClientFactory.Setup(x => x.CreateClient(It.IsAny())).Returns(httpClient); _permissionService = new PermissionServiceMock(); - _controller = new AuthController(configuration, _permissionService); + _controller = new AuthController(configuration, httpClientFactory.Object, _permissionService); } [Test] @@ -57,7 +62,7 @@ public async Task GetAuthStatusReturnsLexboxUserWhenLoggedIn() var result = await _controller.GetAuthStatus() as OkObjectResult; Assert.That(result, Is.Not.Null); - var authStatus = result.Value as AuthStatus; + var authStatus = result.Value as LexboxAuthStatus; Assert.That(authStatus, Is.Not.Null); Assert.That(authStatus.IsLoggedIn, Is.True); Assert.That(authStatus.LoggedInAs, Is.EqualTo("Lex User")); @@ -72,7 +77,7 @@ public async Task GetAuthStatusReturnsLoggedOutWhenNotAuthenticatedByLexboxCooki var result = await _controller.GetAuthStatus() as OkObjectResult; Assert.That(result, Is.Not.Null); - var authStatus = result.Value as AuthStatus; + var authStatus = result.Value as LexboxAuthStatus; Assert.That(authStatus, Is.Not.Null); Assert.That(authStatus.IsLoggedIn, Is.False); Assert.That(authStatus.LoggedInAs, Is.Null); @@ -101,7 +106,7 @@ public async Task GetAuthStatusFallsBackToUserIdWhenDisplayNameClaimsMissing() var result = await _controller.GetAuthStatus() as OkObjectResult; Assert.That(result, Is.Not.Null); - var authStatus = result.Value as AuthStatus; + var authStatus = result.Value as LexboxAuthStatus; Assert.That(authStatus, Is.Not.Null); Assert.That(authStatus.IsLoggedIn, Is.True); Assert.That(authStatus.LoggedInAs, Is.EqualTo("lex-1")); @@ -119,7 +124,7 @@ public async Task GetAuthStatusUsesNameClaimWhenUserClaimMissing() var result = await _controller.GetAuthStatus() as OkObjectResult; Assert.That(result, Is.Not.Null); - var authStatus = result.Value as AuthStatus; + var authStatus = result.Value as LexboxAuthStatus; Assert.That(authStatus, Is.Not.Null); Assert.That(authStatus.IsLoggedIn, Is.True); Assert.That(authStatus.LoggedInAs, Is.EqualTo("Lex Name")); diff --git a/Backend/Controllers/AuthController.cs b/Backend/Controllers/AuthController.cs index b07578e1e8..0a34ed0724 100644 --- a/Backend/Controllers/AuthController.cs +++ b/Backend/Controllers/AuthController.cs @@ -20,12 +20,12 @@ namespace BackendFramework.Controllers { [Produces("application/json")] [Route("v1/auth")] - public class AuthController(IConfiguration configuration, IPermissionService permissionService, - IHttpClientFactory httpClientFactory) : Controller + public class AuthController(IConfiguration configuration, IHttpClientFactory httpClientFactory, + IPermissionService permissionService) : Controller { private readonly IConfiguration _configuration = configuration; - private readonly IPermissionService _permissionService = permissionService; private readonly IHttpClientFactory _httpClientFactory = httpClientFactory; + private readonly IPermissionService _permissionService = permissionService; private const string otelTagName = "otel.AuthController"; private const string LexboxCookieScheme = "LexboxCookie"; @@ -69,7 +69,7 @@ public class AuthController(IConfiguration configuration, IPermissionService per /// Gets authentication status for the current request. [HttpGet("status", Name = "GetAuthStatus")] - [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(AuthStatus))] + [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(LexboxAuthStatus))] [ProducesResponseType(StatusCodes.Status403Forbidden)] public async Task GetAuthStatus() { @@ -88,10 +88,10 @@ public async Task GetAuthStatus() { await HttpContext.SignOutAsync(LexboxCookieScheme); } - return Ok(AuthStatus.LoggedOut()); + return Ok(LexboxAuthStatus.LoggedOut()); } - return Ok(AuthStatus.LoggedIn(GetUserFromClaims(result.Principal))); + return Ok(LexboxAuthStatus.LoggedIn(GetUserFromClaims(result.Principal))); } /// Generates a redirect to Lexbox login for OIDC sign-in. diff --git a/Backend/Models/AuthStatus.cs b/Backend/Models/LexboxAuthStatus.cs similarity index 52% rename from Backend/Models/AuthStatus.cs rename to Backend/Models/LexboxAuthStatus.cs index 6a148cbfda..3d31593149 100644 --- a/Backend/Models/AuthStatus.cs +++ b/Backend/Models/LexboxAuthStatus.cs @@ -1,21 +1,27 @@ namespace BackendFramework.Models { - public class AuthStatus + public class LexboxAuthStatus { public bool IsLoggedIn { get; set; } public string? LoggedInAs { get; set; } public string? UserId { get; set; } - public static AuthStatus LoggedOut() => new() + public static LexboxAuthStatus LoggedOut() => new() { IsLoggedIn = false }; - public static AuthStatus LoggedIn(LexboxAuthUser user) => new() + public static LexboxAuthStatus LoggedIn(LexboxAuthUser user) => new() { IsLoggedIn = true, LoggedInAs = user.DisplayName, UserId = user.UserId }; } + + public class LexboxAuthUser + { + public required string UserId { get; init; } + public required string DisplayName { get; init; } + } } diff --git a/Backend/Models/LexboxAuthUser.cs b/Backend/Models/LexboxAuthUser.cs deleted file mode 100644 index 9fb5356a53..0000000000 --- a/Backend/Models/LexboxAuthUser.cs +++ /dev/null @@ -1,8 +0,0 @@ -namespace BackendFramework.Models -{ - public class LexboxAuthUser - { - public required string UserId { get; init; } - public required string DisplayName { get; init; } - } -} diff --git a/src/api/.openapi-generator/FILES b/src/api/.openapi-generator/FILES index ccad5a79c2..c67462196c 100644 --- a/src/api/.openapi-generator/FILES +++ b/src/api/.openapi-generator/FILES @@ -24,7 +24,6 @@ common.ts configuration.ts git_push.sh index.ts -models/auth-status.ts models/banner-type.ts models/chart-root-data.ts models/consent-type.ts @@ -42,6 +41,7 @@ models/gloss.ts models/gram-cat-group.ts models/grammatical-info.ts models/index.ts +models/lexbox-auth-status.ts models/lexbox-project.ts models/merge-source-word.ts models/merge-undo-ids.ts diff --git a/src/api/api/auth-api.ts b/src/api/api/auth-api.ts index 9f6c32b9fc..997a15d842 100644 --- a/src/api/api/auth-api.ts +++ b/src/api/api/auth-api.ts @@ -37,7 +37,7 @@ import { RequiredError, } from "../base"; // @ts-ignore -import { AuthStatus } from "../models"; +import { LexboxAuthStatus } from "../models"; // @ts-ignore import { LexboxProject } from "../models"; /** @@ -229,7 +229,10 @@ export const AuthApiFp = function (configuration?: Configuration) { async getAuthStatus( options?: any ): Promise< - (axios?: AxiosInstance, basePath?: string) => AxiosPromise + ( + axios?: AxiosInstance, + basePath?: string + ) => AxiosPromise > { const localVarAxiosArgs = await localVarAxiosParamCreator.getAuthStatus(options); @@ -310,7 +313,7 @@ export const AuthApiFactory = function ( * @param {*} [options] Override http request option. * @throws {RequiredError} */ - getAuthStatus(options?: any): AxiosPromise { + getAuthStatus(options?: any): AxiosPromise { return localVarFp .getAuthStatus(options) .then((request) => request(axios, basePath)); diff --git a/src/api/models/index.ts b/src/api/models/index.ts index c0810553fa..cea1f47189 100644 --- a/src/api/models/index.ts +++ b/src/api/models/index.ts @@ -1,4 +1,3 @@ -export * from "./auth-status"; export * from "./banner-type"; export * from "./chart-root-data"; export * from "./consent-type"; @@ -15,6 +14,7 @@ export * from "./flex-project-metadata-dto"; export * from "./gloss"; export * from "./gram-cat-group"; export * from "./grammatical-info"; +export * from "./lexbox-auth-status"; export * from "./lexbox-project"; export * from "./merge-source-word"; export * from "./merge-undo-ids"; diff --git a/src/api/models/auth-status.ts b/src/api/models/lexbox-auth-status.ts similarity index 78% rename from src/api/models/auth-status.ts rename to src/api/models/lexbox-auth-status.ts index 5b29f4ad6f..9978cd014a 100644 --- a/src/api/models/auth-status.ts +++ b/src/api/models/lexbox-auth-status.ts @@ -15,25 +15,25 @@ /** * * @export - * @interface AuthStatus + * @interface LexboxAuthStatus */ -export interface AuthStatus { +export interface LexboxAuthStatus { /** * * @type {boolean} - * @memberof AuthStatus + * @memberof LexboxAuthStatus */ isLoggedIn?: boolean; /** * * @type {string} - * @memberof AuthStatus + * @memberof LexboxAuthStatus */ loggedInAs?: string | null; /** * * @type {string} - * @memberof AuthStatus + * @memberof LexboxAuthStatus */ userId?: string | null; } diff --git a/src/backend/index.ts b/src/backend/index.ts index e3fabc4f30..e2620607e9 100644 --- a/src/backend/index.ts +++ b/src/backend/index.ts @@ -6,10 +6,11 @@ import { enqueueSnackbar } from "notistack"; import * as Api from "api"; import { BASE_PATH } from "api/base"; import { - AuthStatus, BannerType, ChartRootData, EmailInviteStatus, + LexboxAuthStatus, + LexboxProject, MergeUndoIds, MergeWords, Permission, @@ -183,7 +184,7 @@ export function getAudioUrl(wordId: string, fileName: string): string { /* AuthController.cs */ -export async function getLexboxAuthStatus(): Promise { +export async function getLexboxAuthStatus(): Promise { return (await authApi.getAuthStatus(defaultOptions())).data; } @@ -191,6 +192,10 @@ export function getLexboxLoginUrl(): string { return `${baseURL}/auth/lexbox-login`; } +export async function getLexboxProjects(): Promise { + return (await authApi.getLexboxProjects(defaultOptions())).data; +} + export async function logoutLexboxUser(): Promise { await authApi.logOutLexbox(defaultOptions()); } diff --git a/src/components/Lexbox/LexboxLogin.tsx b/src/components/Lexbox/LexboxLogin.tsx index ed69e9669b..36e216d19d 100644 --- a/src/components/Lexbox/LexboxLogin.tsx +++ b/src/components/Lexbox/LexboxLogin.tsx @@ -7,14 +7,15 @@ import { Menu, MenuItem, } from "@mui/material"; -import { ReactElement, useEffect, useState } from "react"; +import { type MouseEvent, type ReactElement, useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { toast } from "react-toastify"; -import { type AuthStatus } from "api/models"; +import { type LexboxAuthStatus } from "api/models"; import { getLexboxAuthStatus, getLexboxLoginUrl, + getLexboxProjects, logoutLexboxUser, } from "backend"; import LoadingButton from "components/Buttons/LoadingButton"; @@ -26,7 +27,7 @@ interface LexboxLoginProps { export default function LexboxLogin(props: LexboxLoginProps): ReactElement { const { t } = useTranslation(); - const [status, setStatus] = useState(); + const [status, setStatus] = useState(); const [statusLoading, setStatusLoading] = useState(true); const [actionLoading, setActionLoading] = useState(false); const [menuAnchor, setMenuAnchor] = useState(null); @@ -53,6 +54,13 @@ export default function LexboxLogin(props: LexboxLoginProps): ReactElement { } }; + const handleClickLoggedIn = async ( + e: MouseEvent + ): Promise => { + console.info(await getLexboxProjects()); + setMenuAnchor(e.currentTarget); + }; + const handleLogout = async (): Promise => { setActionLoading(true); try { @@ -79,7 +87,7 @@ export default function LexboxLogin(props: LexboxLoginProps): ReactElement { return ( <> + {/* Uploaded file name and remove button */} + {lexboxProject && ( + + {t(`Project selected: ${lexboxProject.name}`)} + updateLexboxProject()}> + + + + )} + {/* Lexbox project dialog goes here */} {/* Don't render language pickers until project creation begins. */} {!!(name || languageData || vernLang.name || analysisLang.name) && ( <> {/* Vernacular language picker */} - + {t(CreateProjectTextId.LangVernacular)} {vernLangSelect()} @@ -299,7 +321,7 @@ export default function CreateProject(): ReactElement { )} {/* Analysis language picker */} - + {t(CreateProjectTextId.LangAnalysis)} {languageData ? ( @@ -321,11 +343,7 @@ export default function CreateProject(): ReactElement { )} {/* Form submission button */} - + Date: Wed, 8 Apr 2026 16:04:38 -0400 Subject: [PATCH 33/38] Enable Lexbox project selection --- src/components/Lexbox/LexboxLogin.tsx | 35 ++-- .../Lexbox/LexboxProjectsDialog.tsx | 153 ++++++++++++++++++ .../Lexbox/tests/LexboxLogin.test.tsx | 2 +- .../ProjectScreen/CreateProject.tsx | 39 ++++- 4 files changed, 204 insertions(+), 25 deletions(-) create mode 100644 src/components/Lexbox/LexboxProjectsDialog.tsx diff --git a/src/components/Lexbox/LexboxLogin.tsx b/src/components/Lexbox/LexboxLogin.tsx index b207831939..96b29b9a41 100644 --- a/src/components/Lexbox/LexboxLogin.tsx +++ b/src/components/Lexbox/LexboxLogin.tsx @@ -7,7 +7,7 @@ import { Menu, MenuItem, } from "@mui/material"; -import { type MouseEvent, type ReactElement, useEffect, useState } from "react"; +import { type ReactElement, useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { toast } from "react-toastify"; @@ -15,22 +15,23 @@ import { type LexboxAuthStatus } from "api/models"; import { getLexboxAuthStatus, getLexboxLoginUrl, - getLexboxProjects, logoutLexboxUser, } from "backend"; import LoadingButton from "components/Buttons/LoadingButton"; interface LexboxLoginProps { text?: string; - onStatusChange?: (status: "logged-in" | "logged-out") => void; + onStatusChange?: (loggedIn: boolean) => void; } export default function LexboxLogin(props: LexboxLoginProps): ReactElement { - const { t } = useTranslation(); - const [status, setStatus] = useState(); - const [statusLoading, setStatusLoading] = useState(true); const [actionLoading, setActionLoading] = useState(false); + const [isLoggedIn, setIsLoggedIn] = useState(false); const [menuAnchor, setMenuAnchor] = useState(null); + const [status, setStatus] = useState(); + const [statusLoading, setStatusLoading] = useState(true); + + const { t } = useTranslation(); const loadStatus = async (): Promise => { setStatusLoading(true); @@ -48,29 +49,25 @@ export default function LexboxLogin(props: LexboxLoginProps): ReactElement { loadStatus(); }, []); + useEffect(() => { + setIsLoggedIn(status?.isLoggedIn ?? false); + }, [status?.isLoggedIn]); + + useEffect(() => { + props.onStatusChange?.(isLoggedIn); + }, [props.onStatusChange, isLoggedIn]); + const handleLogin = (): void => { if (!window.open(getLexboxLoginUrl())) { toast.error("Failed to open login window"); } }; - const handleClickLoggedIn = async ( - e: MouseEvent - ): Promise => { - try { - console.info(await getLexboxProjects()); - } catch (err) { - console.error("Failed to get projects", err); - } - setMenuAnchor(e.currentTarget); - }; - const handleLogout = async (): Promise => { setActionLoading(true); try { await logoutLexboxUser(); await loadStatus(); - props.onStatusChange?.("logged-out"); } finally { setActionLoading(false); setMenuAnchor(null); @@ -91,7 +88,7 @@ export default function LexboxLogin(props: LexboxLoginProps): ReactElement { return ( <> + + {t("buttons.confirm")} + + + + ); +} diff --git a/src/components/Lexbox/tests/LexboxLogin.test.tsx b/src/components/Lexbox/tests/LexboxLogin.test.tsx index d533a3abcf..22b4f43255 100644 --- a/src/components/Lexbox/tests/LexboxLogin.test.tsx +++ b/src/components/Lexbox/tests/LexboxLogin.test.tsx @@ -63,6 +63,6 @@ describe("LexboxLogin", () => { expect(mockGetLexboxLoginUrl).not.toHaveBeenCalled(); expect(mockLogoutLexboxUser).toHaveBeenCalledTimes(1); - expect(onStatusChange).toHaveBeenCalledWith("logged-out"); + expect(onStatusChange).toHaveBeenCalledWith(false); }); }); diff --git a/src/components/ProjectScreen/CreateProject.tsx b/src/components/ProjectScreen/CreateProject.tsx index 676b32d792..14a2248104 100644 --- a/src/components/ProjectScreen/CreateProject.tsx +++ b/src/components/ProjectScreen/CreateProject.tsx @@ -20,12 +20,14 @@ import { useState, } from "react"; import { Trans, useTranslation } from "react-i18next"; +import { toast } from "react-toastify"; import { LexboxProject, type WritingSystem } from "api/models"; import { projectDuplicateCheck, uploadLiftAndGetWritingSystems } from "backend"; import FileInputButton from "components/Buttons/FileInputButton"; import LoadingDoneButton from "components/Buttons/LoadingDoneButton"; import LanguagePicker from "components/LanguagePicker"; +import LexboxProjectsDialog from "components/Lexbox/LexboxProjectsDialog"; import { asyncCreateProject, asyncFinishProject, @@ -62,6 +64,7 @@ export default function CreateProject(): ReactElement { const [analysisLang, setAnalysisLang] = useState(newWritingSystem(undBcp47)); const [error, setError] = useState({ empty: false, nameTaken: false }); const [languageData, setLanguageData] = useState(); + const [lexboxDialogOpen, setLexboxDialogOpen] = useState(false); const [lexboxProject, setLexboxProject] = useState< LexboxProject | undefined >(); @@ -217,6 +220,11 @@ export default function CreateProject(): ReactElement { await dispatch(asyncFinishProject(trimmedName, vernLang)).then(() => setSuccess(true) ); + } else if (lexboxProject) { + toast.error( + "Creating project from Lexbox import is not yet implemented." + ); + setLoading(false); } else { await dispatch( asyncCreateProject(trimmedName, vernLang, [analysisLang]) @@ -287,21 +295,42 @@ export default function CreateProject(): ReactElement { {t("Import from Lexbox?")} - + {/* Uploaded file name and remove button */} {lexboxProject && ( - {t(`Project selected: ${lexboxProject.name}`)} + {t( + `Project selected: ${lexboxProject.name} (${lexboxProject.code})` + )} updateLexboxProject()}> )} - {/* Lexbox project dialog goes here */} + { + updateLexboxProject(project); + setLexboxDialogOpen(false); + }} + onClose={() => setLexboxDialogOpen(false)} + open={lexboxDialogOpen} + /> {/* Don't render language pickers until project creation begins. */} - {!!(name || languageData || vernLang.name || analysisLang.name) && ( + {!!( + name || + languageData || + lexboxProject || + vernLang.name || + analysisLang.name + ) && ( <> {/* Vernacular language picker */} @@ -324,7 +353,7 @@ export default function CreateProject(): ReactElement { {t(CreateProjectTextId.LangAnalysis)} - {languageData ? ( + {languageData || lexboxProject ? ( {t(CreateProjectTextId.LangAnalysisInfo)} From e41892b096272de4bd68e6ee970e1ebf7bd01c99 Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Wed, 8 Apr 2026 17:13:13 -0400 Subject: [PATCH 34/38] Create LexboxQueryService --- .../Controllers/AuthControllerTests.cs | 4 +- Backend/Controllers/AuthController.cs | 44 ++++------------- Backend/Models/LexboxQuery.cs | 4 ++ Backend/Services/LexboxQueryService.cs | 48 +++++++++++++++++++ Backend/Startup.cs | 7 ++- 5 files changed, 70 insertions(+), 37 deletions(-) create mode 100644 Backend/Services/LexboxQueryService.cs diff --git a/Backend.Tests/Controllers/AuthControllerTests.cs b/Backend.Tests/Controllers/AuthControllerTests.cs index 639301dc8b..a80b36db8f 100644 --- a/Backend.Tests/Controllers/AuthControllerTests.cs +++ b/Backend.Tests/Controllers/AuthControllerTests.cs @@ -6,6 +6,7 @@ using Backend.Tests.Mocks; using BackendFramework.Controllers; using BackendFramework.Models; +using BackendFramework.Services; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; @@ -38,7 +39,8 @@ public void Setup() var httpClientFactory = new Mock(); httpClientFactory.Setup(x => x.CreateClient(It.IsAny())).Returns(httpClient); _permissionService = new PermissionServiceMock(); - _controller = new AuthController(configuration, httpClientFactory.Object, _permissionService); + var lexboxQueryService = new LexboxQueryService(httpClientFactory.Object); + _controller = new AuthController(configuration, lexboxQueryService, _permissionService); } [Test] diff --git a/Backend/Controllers/AuthController.cs b/Backend/Controllers/AuthController.cs index 9ef52c44fa..1eefcdbe88 100644 --- a/Backend/Controllers/AuthController.cs +++ b/Backend/Controllers/AuthController.cs @@ -1,30 +1,27 @@ using System; -using System.Linq; -using System.Net.Http; -using System.Net.Http.Headers; -using System.Net.Http.Json; +using System.Collections.Generic; using System.Security.Claims; using System.Threading.Tasks; using BackendFramework.Helper; using BackendFramework.Interfaces; using BackendFramework.Models; using BackendFramework.Otel; +using BackendFramework.Services; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Configuration; -using System.Collections.Generic; namespace BackendFramework.Controllers { [Produces("application/json")] [Route("v1/auth")] - public class AuthController(IConfiguration configuration, IHttpClientFactory httpClientFactory, + public class AuthController(IConfiguration configuration, LexboxQueryService lexboxQueryService, IPermissionService permissionService) : Controller { private readonly IConfiguration _configuration = configuration; - private readonly IHttpClientFactory _httpClientFactory = httpClientFactory; + private readonly LexboxQueryService _lexboxQueryService = lexboxQueryService; private readonly IPermissionService _permissionService = permissionService; private const string otelTagName = "otel.AuthController"; @@ -106,39 +103,16 @@ public async Task GetLexboxProjects() return Unauthorized(); } - var httpClient = _httpClientFactory.CreateClient(); - httpClient.DefaultRequestHeaders.Authorization = - new AuthenticationHeaderValue("Bearer", accessToken); - - var response = await httpClient.PostAsJsonAsync(LexboxQuery.QueryUrl, - new LexboxQuery { Query = LexboxQuery.MyProjectsQuery }); - - if (!response.IsSuccessStatusCode) - { - var responseBody = await response.Content.ReadAsStringAsync(); - return Problem(title: "Lexbox GraphQL request failed", - detail: $"Status: {(int)response.StatusCode} {response.ReasonPhrase}" - + (string.IsNullOrEmpty(responseBody) ? "" : $"\nBody: {responseBody}"), - statusCode: StatusCodes.Status502BadGateway); - } - - var graph = await response.Content.ReadFromJsonAsync>(); - if (graph is null) + try { - return Problem(title: "Lexbox GraphQL response was empty", - statusCode: StatusCodes.Status502BadGateway); + var projects = await _lexboxQueryService.GetMyProjectsAsync(accessToken); + return Ok(projects); } - - if (graph.Errors is { Length: > 0 }) + catch (LexboxQueryException ex) { - var errorText = string - .Join("; ", graph.Errors.Select(e => e.Message).Where(m => !string.IsNullOrEmpty(m))); - return Problem(title: "Lexbox GraphQL returned errors", detail: errorText, + return Problem(title: ex.Title, detail: ex.Message, statusCode: StatusCodes.Status502BadGateway); } - - List projects = graph.Data?.MyProjects?.Select(p => new LexboxProject(p)).ToList() ?? []; - return Ok(projects); } private async Task TryGetLexboxAccessTokenAsync() diff --git a/Backend/Models/LexboxQuery.cs b/Backend/Models/LexboxQuery.cs index c770ba930e..4c8c976e8d 100644 --- a/Backend/Models/LexboxQuery.cs +++ b/Backend/Models/LexboxQuery.cs @@ -118,5 +118,9 @@ public static List GetActiveTags(List wsList) } } + public sealed class LexboxQueryException(string title, string detail) : Exception(detail) + { + public string Title { get; } = title; + } } diff --git a/Backend/Services/LexboxQueryService.cs b/Backend/Services/LexboxQueryService.cs new file mode 100644 index 0000000000..5f03bab150 --- /dev/null +++ b/Backend/Services/LexboxQueryService.cs @@ -0,0 +1,48 @@ +using System.Collections.Generic; +using System.Linq; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Net.Http.Json; +using System.Threading.Tasks; +using BackendFramework.Models; + +namespace BackendFramework.Services +{ + public sealed class LexboxQueryService(IHttpClientFactory httpClientFactory) + { + private readonly IHttpClientFactory _httpClientFactory = httpClientFactory; + + public async Task> GetMyProjectsAsync(string accessToken) + { + var httpClient = _httpClientFactory.CreateClient(); + httpClient.DefaultRequestHeaders.Authorization = + new AuthenticationHeaderValue("Bearer", accessToken); + + var response = await httpClient.PostAsJsonAsync(LexboxQuery.QueryUrl, + new LexboxQuery { Query = LexboxQuery.MyProjectsQuery }); + + if (!response.IsSuccessStatusCode) + { + var responseBody = await response.Content.ReadAsStringAsync(); + throw new LexboxQueryException("Lexbox GraphQL request failed", + $"Status: {(int)response.StatusCode} {response.ReasonPhrase}" + + (string.IsNullOrEmpty(responseBody) ? "" : $"\nBody: {responseBody}")); + } + + var graph = await response.Content.ReadFromJsonAsync>(); + if (graph is null) + { + throw new LexboxQueryException("Lexbox GraphQL response was empty", ""); + } + + if (graph.Errors is { Length: > 0 }) + { + var errorText = string.Join("; ", + graph.Errors.Select(e => e.Message).Where(m => !string.IsNullOrEmpty(m))); + throw new LexboxQueryException("Lexbox GraphQL returned errors", errorText); + } + + return graph.Data?.MyProjects?.Select(p => new LexboxProject(p)).ToList() ?? []; + } + } +} diff --git a/Backend/Startup.cs b/Backend/Startup.cs index 7403acaf53..a4154ea0c0 100644 --- a/Backend/Startup.cs +++ b/Backend/Startup.cs @@ -301,6 +301,9 @@ public void ConfigureServices(IServiceCollection services) // Register concrete types for dependency injection + // HttpClientFactory for Lexbox and OpenTelemetry instrumentation + services.AddHttpClient(); + // Mongo context for use in repo contexts services.AddSingleton(); @@ -324,6 +327,9 @@ public void ConfigureServices(IServiceCollection services) services.AddTransient(); services.AddTransient(); + // Lexbox types + services.AddTransient(); + // Lift Service - Singleton to avoid initializing the Sldr multiple times, // also to avoid leaking LanguageTag data services.AddSingleton(); @@ -363,7 +369,6 @@ public void ConfigureServices(IServiceCollection services) services.AddTransient(); // OpenTelemetry - services.AddHttpClient(); services.AddMemoryCache(); services.AddHttpContextAccessor(); services.AddTransient(); From 19390ca3da91203cec3c4b633603aba59a067c2a Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Wed, 8 Apr 2026 17:39:56 -0400 Subject: [PATCH 35/38] Try to fetch project entries --- Backend/Controllers/AuthController.cs | 28 ++++ Backend/Models/LexboxQuery.cs | 45 ++++++ Backend/Services/LexboxQueryService.cs | 20 +++ src/api/.openapi-generator/FILES | 6 + src/api/api/auth-api.ts | 137 ++++++++++++++++++ src/api/models/index.ts | 6 + src/api/models/lexbox-entry.ts | 54 +++++++ src/api/models/lexbox-part-of-speech.ts | 33 +++++ src/api/models/lexbox-project.ts | 6 + src/api/models/lexbox-rich-span.ts | 27 ++++ src/api/models/lexbox-rich-string.ts | 29 ++++ src/api/models/lexbox-semantic-domain.ts | 39 +++++ src/api/models/lexbox-sense.ts | 61 ++++++++ src/backend/index.ts | 13 ++ .../ProjectScreen/CreateProject.tsx | 18 ++- 15 files changed, 520 insertions(+), 2 deletions(-) create mode 100644 src/api/models/lexbox-entry.ts create mode 100644 src/api/models/lexbox-part-of-speech.ts create mode 100644 src/api/models/lexbox-rich-span.ts create mode 100644 src/api/models/lexbox-rich-string.ts create mode 100644 src/api/models/lexbox-semantic-domain.ts create mode 100644 src/api/models/lexbox-sense.ts diff --git a/Backend/Controllers/AuthController.cs b/Backend/Controllers/AuthController.cs index 1eefcdbe88..6e2d7cea9e 100644 --- a/Backend/Controllers/AuthController.cs +++ b/Backend/Controllers/AuthController.cs @@ -115,6 +115,34 @@ public async Task GetLexboxProjects() } } + /// Gets entries for a Lexbox project. + [Authorize(AuthenticationSchemes = LexboxCookieScheme)] + [HttpGet("lexbox-entries/{projectType}/{projectCode}", Name = "GetLexboxEntries")] + [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(List))] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status502BadGateway)] + public async Task GetLexboxEntries(string projectType, string projectCode) + { + using var activity = OtelService.StartActivityWithTag(otelTagName, "getting Lexbox entries"); + + var accessToken = await TryGetLexboxAccessTokenAsync(); + if (string.IsNullOrEmpty(accessToken)) + { + return Unauthorized(); + } + + try + { + var entries = await _lexboxQueryService.GetProjectEntriesAsync(accessToken, projectType, projectCode); + return Ok(entries); + } + catch (LexboxQueryException ex) + { + return Problem(title: ex.Title, detail: ex.Message, + statusCode: StatusCodes.Status502BadGateway); + } + } + private async Task TryGetLexboxAccessTokenAsync() { var result = await HttpContext.AuthenticateAsync(LexboxCookieScheme); diff --git a/Backend/Models/LexboxQuery.cs b/Backend/Models/LexboxQuery.cs index 4c8c976e8d..7a4caf102d 100644 --- a/Backend/Models/LexboxQuery.cs +++ b/Backend/Models/LexboxQuery.cs @@ -7,6 +7,7 @@ namespace BackendFramework.Models public sealed class LexboxQuery { public const string QueryUrl = "https://lexbox.org/api/graphql"; + public const string MiniLcmBaseUrl = "https://lexbox.org/api/mini-lcm"; public const string MyProjectsQuery = @"query { myProjects { code @@ -65,6 +66,7 @@ public sealed class LexboxProject(LexboxProjectDto dto) public Guid Id { get; init; } = dto.Id; public bool? IsConfidential { get; init; } = dto.IsConfidential; public string Name { get; init; } = dto.Name; + public string Type { get; init; } = dto.Type; public List VernacularWsTags { get; init; } = WsIdDto.GetActiveTags(dto.FlexProjectMetadata?.WritingSystems?.VernacularWss ?? []).ToList(); } @@ -123,4 +125,47 @@ public sealed class LexboxQueryException(string title, string detail) : Exceptio public string Title { get; } = title; } + public sealed class LexboxEntry + { + public Guid Id { get; init; } + public Dictionary LexemeForm { get; init; } = []; + public Dictionary CitationForm { get; init; } = []; + public Dictionary Note { get; init; } = []; + public List Senses { get; init; } = []; + } + + public sealed class LexboxSense + { + public Guid Id { get; init; } + public Guid EntryId { get; init; } + public Dictionary Gloss { get; init; } = []; + public Dictionary Definition { get; init; } = []; + public LexboxPartOfSpeech? PartOfSpeech { get; init; } + public List SemanticDomains { get; init; } = []; + } + + public sealed class LexboxPartOfSpeech + { + public Guid Id { get; init; } + public Dictionary Name { get; init; } = []; + } + + public sealed class LexboxSemanticDomain + { + public Guid Id { get; init; } + public Dictionary Name { get; init; } = []; + public string Code { get; init; } = ""; + } + + public sealed class LexboxRichString + { + public List Spans { get; init; } = []; + public string GetPlainText() => string.Concat(Spans.Select(s => s.Text)); + } + + public sealed class LexboxRichSpan + { + public string Text { get; init; } = ""; + } + } diff --git a/Backend/Services/LexboxQueryService.cs b/Backend/Services/LexboxQueryService.cs index 5f03bab150..9f20945a71 100644 --- a/Backend/Services/LexboxQueryService.cs +++ b/Backend/Services/LexboxQueryService.cs @@ -44,5 +44,25 @@ public async Task> GetMyProjectsAsync(string accessToken) return graph.Data?.MyProjects?.Select(p => new LexboxProject(p)).ToList() ?? []; } + + public async Task> GetProjectEntriesAsync(string accessToken, string projectType, string projectCode) + { + var httpClient = _httpClientFactory.CreateClient(); + httpClient.DefaultRequestHeaders.Authorization = + new AuthenticationHeaderValue("Bearer", accessToken); + + var url = $"{LexboxQuery.MiniLcmBaseUrl}/{projectType}/{projectCode}/entries"; + var response = await httpClient.GetAsync(url); + + if (!response.IsSuccessStatusCode) + { + var responseBody = await response.Content.ReadAsStringAsync(); + throw new LexboxQueryException("Lexbox MiniLcm entries request failed", + $"Status: {(int)response.StatusCode} {response.ReasonPhrase}" + + (string.IsNullOrEmpty(responseBody) ? "" : $"\nBody: {responseBody}")); + } + + return await response.Content.ReadFromJsonAsync>() ?? []; + } } } diff --git a/src/api/.openapi-generator/FILES b/src/api/.openapi-generator/FILES index 0bfc1b5dd0..dd3fe0df1c 100644 --- a/src/api/.openapi-generator/FILES +++ b/src/api/.openapi-generator/FILES @@ -40,7 +40,13 @@ models/gram-cat-group.ts models/grammatical-info.ts models/index.ts models/lexbox-auth-status.ts +models/lexbox-entry.ts +models/lexbox-part-of-speech.ts models/lexbox-project.ts +models/lexbox-rich-span.ts +models/lexbox-rich-string.ts +models/lexbox-semantic-domain.ts +models/lexbox-sense.ts models/merge-source-word.ts models/merge-undo-ids.ts models/merge-words.ts diff --git a/src/api/api/auth-api.ts b/src/api/api/auth-api.ts index 997a15d842..f9717bb478 100644 --- a/src/api/api/auth-api.ts +++ b/src/api/api/auth-api.ts @@ -39,6 +39,8 @@ import { // @ts-ignore import { LexboxAuthStatus } from "../models"; // @ts-ignore +import { LexboxEntry } from "../models"; +// @ts-ignore import { LexboxProject } from "../models"; /** * AuthApi - axios parameter creator @@ -120,6 +122,54 @@ export const AuthApiAxiosParamCreator = function ( options: localVarRequestOptions, }; }, + /** + * + * @param {string} projectType + * @param {string} projectCode + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + getLexboxEntries: async ( + projectType: string, + projectCode: string, + options: any = {} + ): Promise => { + // verify required parameter 'projectType' is not null or undefined + assertParamExists("getLexboxEntries", "projectType", projectType); + // verify required parameter 'projectCode' is not null or undefined + assertParamExists("getLexboxEntries", "projectCode", projectCode); + const localVarPath = `/v1/auth/lexbox-entries/{projectType}/{projectCode}` + .replace(`{${"projectType"}}`, encodeURIComponent(String(projectType))) + .replace(`{${"projectCode"}}`, encodeURIComponent(String(projectCode))); + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { + method: "GET", + ...baseOptions, + ...options, + }; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + setSearchParams(localVarUrlObj, localVarQueryParameter, options.query); + let headersFromBaseOptions = + baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = { + ...localVarHeaderParameter, + ...headersFromBaseOptions, + ...options.headers, + }; + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, /** * * @param {*} [options] Override http request option. @@ -243,6 +293,36 @@ export const AuthApiFp = function (configuration?: Configuration) { configuration ); }, + /** + * + * @param {string} projectType + * @param {string} projectCode + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async getLexboxEntries( + projectType: string, + projectCode: string, + options?: any + ): Promise< + ( + axios?: AxiosInstance, + basePath?: string + ) => AxiosPromise> + > { + const localVarAxiosArgs = + await localVarAxiosParamCreator.getLexboxEntries( + projectType, + projectCode, + options + ); + return createRequestFunction( + localVarAxiosArgs, + globalAxios, + BASE_PATH, + configuration + ); + }, /** * * @param {*} [options] Override http request option. @@ -318,6 +398,22 @@ export const AuthApiFactory = function ( .getAuthStatus(options) .then((request) => request(axios, basePath)); }, + /** + * + * @param {string} projectType + * @param {string} projectCode + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + getLexboxEntries( + projectType: string, + projectCode: string, + options?: any + ): AxiosPromise> { + return localVarFp + .getLexboxEntries(projectType, projectCode, options) + .then((request) => request(axios, basePath)); + }, /** * * @param {*} [options] Override http request option. @@ -341,6 +437,27 @@ export const AuthApiFactory = function ( }; }; +/** + * Request parameters for getLexboxEntries operation in AuthApi. + * @export + * @interface AuthApiGetLexboxEntriesRequest + */ +export interface AuthApiGetLexboxEntriesRequest { + /** + * + * @type {string} + * @memberof AuthApiGetLexboxEntries + */ + readonly projectType: string; + + /** + * + * @type {string} + * @memberof AuthApiGetLexboxEntries + */ + readonly projectCode: string; +} + /** * AuthApi - object-oriented interface * @export @@ -372,6 +489,26 @@ export class AuthApi extends BaseAPI { .then((request) => request(this.axios, this.basePath)); } + /** + * + * @param {AuthApiGetLexboxEntriesRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof AuthApi + */ + public getLexboxEntries( + requestParameters: AuthApiGetLexboxEntriesRequest, + options?: any + ) { + return AuthApiFp(this.configuration) + .getLexboxEntries( + requestParameters.projectType, + requestParameters.projectCode, + options + ) + .then((request) => request(this.axios, this.basePath)); + } + /** * * @param {*} [options] Override http request option. diff --git a/src/api/models/index.ts b/src/api/models/index.ts index b5cdbf1a4f..823e652603 100644 --- a/src/api/models/index.ts +++ b/src/api/models/index.ts @@ -13,7 +13,13 @@ export * from "./gloss"; export * from "./gram-cat-group"; export * from "./grammatical-info"; export * from "./lexbox-auth-status"; +export * from "./lexbox-entry"; +export * from "./lexbox-part-of-speech"; export * from "./lexbox-project"; +export * from "./lexbox-rich-span"; +export * from "./lexbox-rich-string"; +export * from "./lexbox-semantic-domain"; +export * from "./lexbox-sense"; export * from "./merge-source-word"; export * from "./merge-undo-ids"; export * from "./merge-words"; diff --git a/src/api/models/lexbox-entry.ts b/src/api/models/lexbox-entry.ts new file mode 100644 index 0000000000..e8948bc31c --- /dev/null +++ b/src/api/models/lexbox-entry.ts @@ -0,0 +1,54 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * BackendFramework + * No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) + * + * The version of the OpenAPI document: 1.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + +import { LexboxRichString } from "./lexbox-rich-string"; +import { LexboxSense } from "./lexbox-sense"; + +/** + * + * @export + * @interface LexboxEntry + */ +export interface LexboxEntry { + /** + * + * @type {string} + * @memberof LexboxEntry + */ + id?: string; + /** + * + * @type {{ [key: string]: string; }} + * @memberof LexboxEntry + */ + lexemeForm?: { [key: string]: string } | null; + /** + * + * @type {{ [key: string]: string; }} + * @memberof LexboxEntry + */ + citationForm?: { [key: string]: string } | null; + /** + * + * @type {{ [key: string]: LexboxRichString; }} + * @memberof LexboxEntry + */ + note?: { [key: string]: LexboxRichString } | null; + /** + * + * @type {Array} + * @memberof LexboxEntry + */ + senses?: Array | null; +} diff --git a/src/api/models/lexbox-part-of-speech.ts b/src/api/models/lexbox-part-of-speech.ts new file mode 100644 index 0000000000..9e88a6c1bd --- /dev/null +++ b/src/api/models/lexbox-part-of-speech.ts @@ -0,0 +1,33 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * BackendFramework + * No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) + * + * The version of the OpenAPI document: 1.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + +/** + * + * @export + * @interface LexboxPartOfSpeech + */ +export interface LexboxPartOfSpeech { + /** + * + * @type {string} + * @memberof LexboxPartOfSpeech + */ + id?: string; + /** + * + * @type {{ [key: string]: string; }} + * @memberof LexboxPartOfSpeech + */ + name?: { [key: string]: string } | null; +} diff --git a/src/api/models/lexbox-project.ts b/src/api/models/lexbox-project.ts index 397e127cf4..2f47a84090 100644 --- a/src/api/models/lexbox-project.ts +++ b/src/api/models/lexbox-project.ts @@ -54,6 +54,12 @@ export interface LexboxProject { * @memberof LexboxProject */ name?: string | null; + /** + * + * @type {string} + * @memberof LexboxProject + */ + type?: string | null; /** * * @type {Array} diff --git a/src/api/models/lexbox-rich-span.ts b/src/api/models/lexbox-rich-span.ts new file mode 100644 index 0000000000..457eb6e678 --- /dev/null +++ b/src/api/models/lexbox-rich-span.ts @@ -0,0 +1,27 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * BackendFramework + * No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) + * + * The version of the OpenAPI document: 1.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + +/** + * + * @export + * @interface LexboxRichSpan + */ +export interface LexboxRichSpan { + /** + * + * @type {string} + * @memberof LexboxRichSpan + */ + text?: string | null; +} diff --git a/src/api/models/lexbox-rich-string.ts b/src/api/models/lexbox-rich-string.ts new file mode 100644 index 0000000000..1b9e7af458 --- /dev/null +++ b/src/api/models/lexbox-rich-string.ts @@ -0,0 +1,29 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * BackendFramework + * No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) + * + * The version of the OpenAPI document: 1.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + +import { LexboxRichSpan } from "./lexbox-rich-span"; + +/** + * + * @export + * @interface LexboxRichString + */ +export interface LexboxRichString { + /** + * + * @type {Array} + * @memberof LexboxRichString + */ + spans?: Array | null; +} diff --git a/src/api/models/lexbox-semantic-domain.ts b/src/api/models/lexbox-semantic-domain.ts new file mode 100644 index 0000000000..e0af134a45 --- /dev/null +++ b/src/api/models/lexbox-semantic-domain.ts @@ -0,0 +1,39 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * BackendFramework + * No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) + * + * The version of the OpenAPI document: 1.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + +/** + * + * @export + * @interface LexboxSemanticDomain + */ +export interface LexboxSemanticDomain { + /** + * + * @type {string} + * @memberof LexboxSemanticDomain + */ + id?: string; + /** + * + * @type {{ [key: string]: string; }} + * @memberof LexboxSemanticDomain + */ + name?: { [key: string]: string } | null; + /** + * + * @type {string} + * @memberof LexboxSemanticDomain + */ + code?: string | null; +} diff --git a/src/api/models/lexbox-sense.ts b/src/api/models/lexbox-sense.ts new file mode 100644 index 0000000000..93ff0f65a9 --- /dev/null +++ b/src/api/models/lexbox-sense.ts @@ -0,0 +1,61 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * BackendFramework + * No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) + * + * The version of the OpenAPI document: 1.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + +import { LexboxPartOfSpeech } from "./lexbox-part-of-speech"; +import { LexboxRichString } from "./lexbox-rich-string"; +import { LexboxSemanticDomain } from "./lexbox-semantic-domain"; + +/** + * + * @export + * @interface LexboxSense + */ +export interface LexboxSense { + /** + * + * @type {string} + * @memberof LexboxSense + */ + id?: string; + /** + * + * @type {string} + * @memberof LexboxSense + */ + entryId?: string; + /** + * + * @type {{ [key: string]: string; }} + * @memberof LexboxSense + */ + gloss?: { [key: string]: string } | null; + /** + * + * @type {{ [key: string]: LexboxRichString; }} + * @memberof LexboxSense + */ + definition?: { [key: string]: LexboxRichString } | null; + /** + * + * @type {LexboxPartOfSpeech} + * @memberof LexboxSense + */ + partOfSpeech?: LexboxPartOfSpeech; + /** + * + * @type {Array} + * @memberof LexboxSense + */ + semanticDomains?: Array | null; +} diff --git a/src/backend/index.ts b/src/backend/index.ts index e2620607e9..ffdb43b57c 100644 --- a/src/backend/index.ts +++ b/src/backend/index.ts @@ -10,6 +10,7 @@ import { ChartRootData, EmailInviteStatus, LexboxAuthStatus, + LexboxEntry, LexboxProject, MergeUndoIds, MergeWords, @@ -196,6 +197,18 @@ export async function getLexboxProjects(): Promise { return (await authApi.getLexboxProjects(defaultOptions())).data; } +export async function getLexboxEntries( + projectType: string, + projectCode: string +): Promise { + return ( + await authApi.getLexboxEntries( + { projectType, projectCode }, + defaultOptions() + ) + ).data; +} + export async function logoutLexboxUser(): Promise { await authApi.logOutLexbox(defaultOptions()); } diff --git a/src/components/ProjectScreen/CreateProject.tsx b/src/components/ProjectScreen/CreateProject.tsx index 14a2248104..51ebb61e5b 100644 --- a/src/components/ProjectScreen/CreateProject.tsx +++ b/src/components/ProjectScreen/CreateProject.tsx @@ -23,7 +23,11 @@ import { Trans, useTranslation } from "react-i18next"; import { toast } from "react-toastify"; import { LexboxProject, type WritingSystem } from "api/models"; -import { projectDuplicateCheck, uploadLiftAndGetWritingSystems } from "backend"; +import { + getLexboxEntries, + projectDuplicateCheck, + uploadLiftAndGetWritingSystems, +} from "backend"; import FileInputButton from "components/Buttons/FileInputButton"; import LoadingDoneButton from "components/Buttons/LoadingDoneButton"; import LanguagePicker from "components/LanguagePicker"; @@ -220,7 +224,17 @@ export default function CreateProject(): ReactElement { await dispatch(asyncFinishProject(trimmedName, vernLang)).then(() => setSuccess(true) ); - } else if (lexboxProject) { + } else if (lexboxProject?.type && lexboxProject?.code) { + try { + console.info( + "Project entries:", + ( + await getLexboxEntries(lexboxProject.type, lexboxProject.code) + ).slice(0, 5) + ); + } catch (e) { + console.error("Error fetching Lexbox entries:", e); + } toast.error( "Creating project from Lexbox import is not yet implemented." ); From 117621dfd55abceb56068fa059565beb4f4b9e5f Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Fri, 10 Apr 2026 12:47:39 -0400 Subject: [PATCH 36/38] Use LF api --- ...ollerTests.cs => LexboxControllerTests.cs} | 8 +- ...{AuthController.cs => LexboxController.cs} | 20 ++-- Backend/Interfaces/ILexboxQueryService.cs | 12 ++ Backend/Models/LexboxQuery.cs | 79 ++++++++++++- Backend/Services/LexboxQueryService.cs | 30 +++-- Backend/Startup.cs | 2 +- src/api/.openapi-generator/FILES | 8 +- src/api/api.ts | 2 +- src/api/api/{auth-api.ts => lexbox-api.ts} | 106 +++++++++--------- src/api/models/index.ts | 6 - src/api/models/lexbox-entry.ts | 54 --------- src/api/models/lexbox-part-of-speech.ts | 33 ------ src/api/models/lexbox-rich-span.ts | 27 ----- src/api/models/lexbox-rich-string.ts | 29 ----- src/api/models/lexbox-semantic-domain.ts | 39 ------- src/api/models/lexbox-sense.ts | 61 ---------- src/backend/index.ts | 59 +++++----- .../ProjectScreen/CreateProject.tsx | 6 +- 18 files changed, 207 insertions(+), 374 deletions(-) rename Backend.Tests/Controllers/{AuthControllerTests.cs => LexboxControllerTests.cs} (96%) rename Backend/Controllers/{AuthController.cs => LexboxController.cs} (91%) create mode 100644 Backend/Interfaces/ILexboxQueryService.cs rename src/api/api/{auth-api.ts => lexbox-api.ts} (85%) delete mode 100644 src/api/models/lexbox-entry.ts delete mode 100644 src/api/models/lexbox-part-of-speech.ts delete mode 100644 src/api/models/lexbox-rich-span.ts delete mode 100644 src/api/models/lexbox-rich-string.ts delete mode 100644 src/api/models/lexbox-semantic-domain.ts delete mode 100644 src/api/models/lexbox-sense.ts diff --git a/Backend.Tests/Controllers/AuthControllerTests.cs b/Backend.Tests/Controllers/LexboxControllerTests.cs similarity index 96% rename from Backend.Tests/Controllers/AuthControllerTests.cs rename to Backend.Tests/Controllers/LexboxControllerTests.cs index a80b36db8f..e2cfa24482 100644 --- a/Backend.Tests/Controllers/AuthControllerTests.cs +++ b/Backend.Tests/Controllers/LexboxControllerTests.cs @@ -17,12 +17,12 @@ namespace Backend.Tests.Controllers { - internal sealed class AuthControllerTests : IDisposable + internal sealed class LexboxControllerTests : IDisposable { private PermissionServiceMock _permissionService = null!; - private AuthController _controller = null!; + private LexboxController _controller = null!; - private const string UserId = "AuthControllerTestsUserId"; + private const string UserId = "LexboxControllerTestsUserId"; public void Dispose() { @@ -40,7 +40,7 @@ public void Setup() httpClientFactory.Setup(x => x.CreateClient(It.IsAny())).Returns(httpClient); _permissionService = new PermissionServiceMock(); var lexboxQueryService = new LexboxQueryService(httpClientFactory.Object); - _controller = new AuthController(configuration, lexboxQueryService, _permissionService); + _controller = new LexboxController(configuration, lexboxQueryService, _permissionService); } [Test] diff --git a/Backend/Controllers/AuthController.cs b/Backend/Controllers/LexboxController.cs similarity index 91% rename from Backend/Controllers/AuthController.cs rename to Backend/Controllers/LexboxController.cs index 6e2d7cea9e..59f92e7cbd 100644 --- a/Backend/Controllers/AuthController.cs +++ b/Backend/Controllers/LexboxController.cs @@ -6,7 +6,6 @@ using BackendFramework.Interfaces; using BackendFramework.Models; using BackendFramework.Otel; -using BackendFramework.Services; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Http; @@ -17,14 +16,14 @@ namespace BackendFramework.Controllers { [Produces("application/json")] [Route("v1/auth")] - public class AuthController(IConfiguration configuration, LexboxQueryService lexboxQueryService, + public class LexboxController(IConfiguration configuration, ILexboxQueryService lexboxQueryService, IPermissionService permissionService) : Controller { private readonly IConfiguration _configuration = configuration; - private readonly LexboxQueryService _lexboxQueryService = lexboxQueryService; + private readonly ILexboxQueryService _lexboxQueryService = lexboxQueryService; private readonly IPermissionService _permissionService = permissionService; - private const string otelTagName = "otel.AuthController"; + private const string otelTagName = "otel.LexboxController"; private const string LexboxCookieScheme = "LexboxCookie"; private const string LexboxOidcScheme = "LexboxOidc"; private const string PostLoginRedirectConfigKey = "LexboxAuth:PostLoginRedirect"; @@ -105,7 +104,7 @@ public async Task GetLexboxProjects() try { - var projects = await _lexboxQueryService.GetMyProjectsAsync(accessToken); + List projects = await _lexboxQueryService.GetMyProjectsAsync(accessToken); return Ok(projects); } catch (LexboxQueryException ex) @@ -115,13 +114,13 @@ public async Task GetLexboxProjects() } } - /// Gets entries for a Lexbox project. + /// Gets entries from a Lexbox project. [Authorize(AuthenticationSchemes = LexboxCookieScheme)] - [HttpGet("lexbox-entries/{projectType}/{projectCode}", Name = "GetLexboxEntries")] - [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(List))] + [HttpGet("lexbox-entries/{projectCode}/{vernacularLang}", Name = "GetLexboxEntries")] + [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(List))] [ProducesResponseType(StatusCodes.Status401Unauthorized)] [ProducesResponseType(StatusCodes.Status502BadGateway)] - public async Task GetLexboxEntries(string projectType, string projectCode) + public async Task GetLexboxEntries(string projectCode, string vernacularLang) { using var activity = OtelService.StartActivityWithTag(otelTagName, "getting Lexbox entries"); @@ -133,7 +132,8 @@ public async Task GetLexboxEntries(string projectType, string pro try { - var entries = await _lexboxQueryService.GetProjectEntriesAsync(accessToken, projectType, projectCode); + List entries = + await _lexboxQueryService.GetProjectEntriesAsync(accessToken, projectCode, vernacularLang); return Ok(entries); } catch (LexboxQueryException ex) diff --git a/Backend/Interfaces/ILexboxQueryService.cs b/Backend/Interfaces/ILexboxQueryService.cs new file mode 100644 index 0000000000..d9a62b7d2d --- /dev/null +++ b/Backend/Interfaces/ILexboxQueryService.cs @@ -0,0 +1,12 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using BackendFramework.Models; + +namespace BackendFramework.Interfaces +{ + public interface ILexboxQueryService + { + Task> GetMyProjectsAsync(string accessToken); + Task> GetProjectEntriesAsync(string accessToken, string projectCode, string vernacularLang); + } +} diff --git a/Backend/Models/LexboxQuery.cs b/Backend/Models/LexboxQuery.cs index 7a4caf102d..53caf7e237 100644 --- a/Backend/Models/LexboxQuery.cs +++ b/Backend/Models/LexboxQuery.cs @@ -8,6 +8,7 @@ public sealed class LexboxQuery { public const string QueryUrl = "https://lexbox.org/api/graphql"; public const string MiniLcmBaseUrl = "https://lexbox.org/api/mini-lcm"; + public const string LfClassicBaseUrl = "https://lexbox.org/api/lfclassic"; public const string MyProjectsQuery = @"query { myProjects { code @@ -127,39 +128,107 @@ public sealed class LexboxQueryException(string title, string detail) : Exceptio public sealed class LexboxEntry { + public Dictionary CitationForm { get; init; } = []; + public DateTime? DeletedAt { get; init; } public Guid Id { get; init; } public Dictionary LexemeForm { get; init; } = []; - public Dictionary CitationForm { get; init; } = []; public Dictionary Note { get; init; } = []; public List Senses { get; init; } = []; + + public Word? ToWord(string vernacularLang, IEnumerable? analysisLangs = null) + { + if (DeletedAt is not null) + { + // Ignore any entry that was deleted. + return null; + } + + CitationForm.TryGetValue(vernacularLang, out var vernacular); + var usingCitationForm = !string.IsNullOrWhiteSpace(vernacular); + if (!usingCitationForm) + { + LexemeForm.TryGetValue(vernacularLang, out vernacular); + } + vernacular = vernacular?.Trim(); + if (string.IsNullOrEmpty(vernacular)) + { + // Ignore any entry with no citation/lexeme form in specified vernacular language. + return null; + } + + var noteLang = (analysisLangs ?? []).FirstOrDefault(Note.ContainsKey) ?? Note.Keys.FirstOrDefault() ?? ""; + var noteText = noteLang.Length > 0 && Note.TryGetValue(noteLang, out var noteRich) + ? noteRich.GetPlainText() + : ""; + + return new Word + { + Guid = Id, + Id = Guid.NewGuid().ToString(), + Note = new Note(noteLang, noteText), + Senses = Senses + .Select(s => s.ToSense(analysisLangs)).Where(s => s is not null).OfType().ToList(), + UsingCitationForm = usingCitationForm, + Vernacular = vernacular, + }; + } } public sealed class LexboxSense { - public Guid Id { get; init; } - public Guid EntryId { get; init; } - public Dictionary Gloss { get; init; } = []; public Dictionary Definition { get; init; } = []; + public DateTime? DeletedAt { get; init; } + public Dictionary Gloss { get; init; } = []; + public Guid Id { get; init; } public LexboxPartOfSpeech? PartOfSpeech { get; init; } public List SemanticDomains { get; init; } = []; + + public Sense? ToSense(IEnumerable? langs = null) + { + // Ignore any sense that was deleted. + return DeletedAt is null ? null : new() + { + Definitions = Definition + .Select(kvp => new Definition { Language = kvp.Key, Text = kvp.Value.GetPlainText() }).ToList(), + Glosses = Gloss.Select(kvp => new Gloss { Def = kvp.Value, Language = kvp.Key }).ToList(), + GrammaticalInfo = PartOfSpeech?.ToGrammaticalInfo(langs) ?? new GrammaticalInfo(), + Guid = Id, + SemanticDomains = SemanticDomains.Select(sd => sd.ToSemanticDomain(langs)).ToList(), + }; + } } public sealed class LexboxPartOfSpeech { public Guid Id { get; init; } public Dictionary Name { get; init; } = []; + + public GrammaticalInfo ToGrammaticalInfo(IEnumerable? langs = null) + { + var resolvedLang = (langs ?? []).FirstOrDefault(Name.ContainsKey) ?? Name.Keys.FirstOrDefault() ?? ""; + var name = resolvedLang.Length > 0 && Name.TryGetValue(resolvedLang, out var langName) ? langName : ""; + return new GrammaticalInfo(name); + } } public sealed class LexboxSemanticDomain { + public string Code { get; init; } = ""; public Guid Id { get; init; } public Dictionary Name { get; init; } = []; - public string Code { get; init; } = ""; + + public SemanticDomain ToSemanticDomain(IEnumerable? langs = null) + { + var resolvedLang = (langs ?? []).FirstOrDefault(Name.ContainsKey) ?? Name.Keys.FirstOrDefault() ?? ""; + var name = resolvedLang.Length > 0 && Name.TryGetValue(resolvedLang, out var langName) ? langName : ""; + return new SemanticDomain { Guid = Id.ToString(), Id = Code, Lang = resolvedLang, Name = name }; + } } public sealed class LexboxRichString { public List Spans { get; init; } = []; + public string GetPlainText() => string.Concat(Spans.Select(s => s.Text)); } diff --git a/Backend/Services/LexboxQueryService.cs b/Backend/Services/LexboxQueryService.cs index 9f20945a71..20f15dc02f 100644 --- a/Backend/Services/LexboxQueryService.cs +++ b/Backend/Services/LexboxQueryService.cs @@ -4,11 +4,12 @@ using System.Net.Http.Headers; using System.Net.Http.Json; using System.Threading.Tasks; +using BackendFramework.Interfaces; using BackendFramework.Models; namespace BackendFramework.Services { - public sealed class LexboxQueryService(IHttpClientFactory httpClientFactory) + public sealed class LexboxQueryService(IHttpClientFactory httpClientFactory) : ILexboxQueryService { private readonly IHttpClientFactory _httpClientFactory = httpClientFactory; @@ -29,11 +30,8 @@ public async Task> GetMyProjectsAsync(string accessToken) + (string.IsNullOrEmpty(responseBody) ? "" : $"\nBody: {responseBody}")); } - var graph = await response.Content.ReadFromJsonAsync>(); - if (graph is null) - { - throw new LexboxQueryException("Lexbox GraphQL response was empty", ""); - } + var graph = await response.Content.ReadFromJsonAsync>() + ?? throw new LexboxQueryException("Lexbox GraphQL response was empty", ""); if (graph.Errors is { Length: > 0 }) { @@ -45,24 +43,36 @@ public async Task> GetMyProjectsAsync(string accessToken) return graph.Data?.MyProjects?.Select(p => new LexboxProject(p)).ToList() ?? []; } - public async Task> GetProjectEntriesAsync(string accessToken, string projectType, string projectCode) + public async Task> GetProjectEntriesAsync( + string accessToken, string projectCode, string vernacularLang) { var httpClient = _httpClientFactory.CreateClient(); httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", accessToken); - var url = $"{LexboxQuery.MiniLcmBaseUrl}/{projectType}/{projectCode}/entries"; + var url = $"{LexboxQuery.LfClassicBaseUrl}/{projectCode}/entries"; var response = await httpClient.GetAsync(url); + if (response.StatusCode == System.Net.HttpStatusCode.NotFound) + { + throw new LexboxQueryException("Language Forge project not found", + $"Project '{projectCode}' was not found in Language Forge."); + } + if (!response.IsSuccessStatusCode) { var responseBody = await response.Content.ReadAsStringAsync(); - throw new LexboxQueryException("Lexbox MiniLcm entries request failed", + throw new LexboxQueryException("Project entries request failed", $"Status: {(int)response.StatusCode} {response.ReasonPhrase}" + (string.IsNullOrEmpty(responseBody) ? "" : $"\nBody: {responseBody}")); } - return await response.Content.ReadFromJsonAsync>() ?? []; + var lexboxEntries = await response.Content.ReadFromJsonAsync>() ?? []; + return lexboxEntries + .Where(e => e.DeletedAt is null) + .Select(e => e.ToWord(vernacularLang)) + .OfType() + .ToList(); } } } diff --git a/Backend/Startup.cs b/Backend/Startup.cs index a4154ea0c0..76bca448be 100644 --- a/Backend/Startup.cs +++ b/Backend/Startup.cs @@ -328,7 +328,7 @@ public void ConfigureServices(IServiceCollection services) services.AddTransient(); // Lexbox types - services.AddTransient(); + services.AddTransient(); // Lift Service - Singleton to avoid initializing the Sldr multiple times, // also to avoid leaking LanguageTag data diff --git a/src/api/.openapi-generator/FILES b/src/api/.openapi-generator/FILES index dd3fe0df1c..5c64f783d6 100644 --- a/src/api/.openapi-generator/FILES +++ b/src/api/.openapi-generator/FILES @@ -3,11 +3,11 @@ .openapi-generator-ignore api.ts api/audio-api.ts -api/auth-api.ts api/avatar-api.ts api/banner-api.ts api/email-verify-api.ts api/invite-api.ts +api/lexbox-api.ts api/lift-api.ts api/merge-api.ts api/password-reset-api.ts @@ -40,13 +40,7 @@ models/gram-cat-group.ts models/grammatical-info.ts models/index.ts models/lexbox-auth-status.ts -models/lexbox-entry.ts -models/lexbox-part-of-speech.ts models/lexbox-project.ts -models/lexbox-rich-span.ts -models/lexbox-rich-string.ts -models/lexbox-semantic-domain.ts -models/lexbox-sense.ts models/merge-source-word.ts models/merge-undo-ids.ts models/merge-words.ts diff --git a/src/api/api.ts b/src/api/api.ts index 6c1e467881..25587ab6f6 100644 --- a/src/api/api.ts +++ b/src/api/api.ts @@ -13,11 +13,11 @@ */ export * from "./api/audio-api"; -export * from "./api/auth-api"; export * from "./api/avatar-api"; export * from "./api/banner-api"; export * from "./api/email-verify-api"; export * from "./api/invite-api"; +export * from "./api/lexbox-api"; export * from "./api/lift-api"; export * from "./api/merge-api"; export * from "./api/password-reset-api"; diff --git a/src/api/api/auth-api.ts b/src/api/api/lexbox-api.ts similarity index 85% rename from src/api/api/auth-api.ts rename to src/api/api/lexbox-api.ts index f9717bb478..c16e32d1de 100644 --- a/src/api/api/auth-api.ts +++ b/src/api/api/lexbox-api.ts @@ -39,14 +39,14 @@ import { // @ts-ignore import { LexboxAuthStatus } from "../models"; // @ts-ignore -import { LexboxEntry } from "../models"; -// @ts-ignore import { LexboxProject } from "../models"; +// @ts-ignore +import { Word } from "../models"; /** - * AuthApi - axios parameter creator + * LexboxApi - axios parameter creator * @export */ -export const AuthApiAxiosParamCreator = function ( +export const LexboxApiAxiosParamCreator = function ( configuration?: Configuration ) { return { @@ -124,23 +124,30 @@ export const AuthApiAxiosParamCreator = function ( }, /** * - * @param {string} projectType * @param {string} projectCode + * @param {string} vernacularLang * @param {*} [options] Override http request option. * @throws {RequiredError} */ getLexboxEntries: async ( - projectType: string, projectCode: string, + vernacularLang: string, options: any = {} ): Promise => { - // verify required parameter 'projectType' is not null or undefined - assertParamExists("getLexboxEntries", "projectType", projectType); // verify required parameter 'projectCode' is not null or undefined assertParamExists("getLexboxEntries", "projectCode", projectCode); - const localVarPath = `/v1/auth/lexbox-entries/{projectType}/{projectCode}` - .replace(`{${"projectType"}}`, encodeURIComponent(String(projectType))) - .replace(`{${"projectCode"}}`, encodeURIComponent(String(projectCode))); + // verify required parameter 'vernacularLang' is not null or undefined + assertParamExists("getLexboxEntries", "vernacularLang", vernacularLang); + const localVarPath = + `/v1/auth/lexbox-entries/{projectCode}/{vernacularLang}` + .replace( + `{${"projectCode"}}`, + encodeURIComponent(String(projectCode)) + ) + .replace( + `{${"vernacularLang"}}`, + encodeURIComponent(String(vernacularLang)) + ); // use dummy base URL string because the URL constructor only accepts absolute URLs. const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); let baseOptions; @@ -246,11 +253,11 @@ export const AuthApiAxiosParamCreator = function ( }; /** - * AuthApi - functional programming interface + * LexboxApi - functional programming interface * @export */ -export const AuthApiFp = function (configuration?: Configuration) { - const localVarAxiosParamCreator = AuthApiAxiosParamCreator(configuration); +export const LexboxApiFp = function (configuration?: Configuration) { + const localVarAxiosParamCreator = LexboxApiAxiosParamCreator(configuration); return { /** * @@ -295,25 +302,22 @@ export const AuthApiFp = function (configuration?: Configuration) { }, /** * - * @param {string} projectType * @param {string} projectCode + * @param {string} vernacularLang * @param {*} [options] Override http request option. * @throws {RequiredError} */ async getLexboxEntries( - projectType: string, projectCode: string, + vernacularLang: string, options?: any ): Promise< - ( - axios?: AxiosInstance, - basePath?: string - ) => AxiosPromise> + (axios?: AxiosInstance, basePath?: string) => AxiosPromise> > { const localVarAxiosArgs = await localVarAxiosParamCreator.getLexboxEntries( - projectType, projectCode, + vernacularLang, options ); return createRequestFunction( @@ -368,15 +372,15 @@ export const AuthApiFp = function (configuration?: Configuration) { }; /** - * AuthApi - factory interface + * LexboxApi - factory interface * @export */ -export const AuthApiFactory = function ( +export const LexboxApiFactory = function ( configuration?: Configuration, basePath?: string, axios?: AxiosInstance ) { - const localVarFp = AuthApiFp(configuration); + const localVarFp = LexboxApiFp(configuration); return { /** * @@ -400,18 +404,18 @@ export const AuthApiFactory = function ( }, /** * - * @param {string} projectType * @param {string} projectCode + * @param {string} vernacularLang * @param {*} [options] Override http request option. * @throws {RequiredError} */ getLexboxEntries( - projectType: string, projectCode: string, + vernacularLang: string, options?: any - ): AxiosPromise> { + ): AxiosPromise> { return localVarFp - .getLexboxEntries(projectType, projectCode, options) + .getLexboxEntries(projectCode, vernacularLang, options) .then((request) => request(axios, basePath)); }, /** @@ -438,41 +442,41 @@ export const AuthApiFactory = function ( }; /** - * Request parameters for getLexboxEntries operation in AuthApi. + * Request parameters for getLexboxEntries operation in LexboxApi. * @export - * @interface AuthApiGetLexboxEntriesRequest + * @interface LexboxApiGetLexboxEntriesRequest */ -export interface AuthApiGetLexboxEntriesRequest { +export interface LexboxApiGetLexboxEntriesRequest { /** * * @type {string} - * @memberof AuthApiGetLexboxEntries + * @memberof LexboxApiGetLexboxEntries */ - readonly projectType: string; + readonly projectCode: string; /** * * @type {string} - * @memberof AuthApiGetLexboxEntries + * @memberof LexboxApiGetLexboxEntries */ - readonly projectCode: string; + readonly vernacularLang: string; } /** - * AuthApi - object-oriented interface + * LexboxApi - object-oriented interface * @export - * @class AuthApi + * @class LexboxApi * @extends {BaseAPI} */ -export class AuthApi extends BaseAPI { +export class LexboxApi extends BaseAPI { /** * * @param {*} [options] Override http request option. * @throws {RequiredError} - * @memberof AuthApi + * @memberof LexboxApi */ public generateLexboxLogin(options?: any) { - return AuthApiFp(this.configuration) + return LexboxApiFp(this.configuration) .generateLexboxLogin(options) .then((request) => request(this.axios, this.basePath)); } @@ -481,29 +485,29 @@ export class AuthApi extends BaseAPI { * * @param {*} [options] Override http request option. * @throws {RequiredError} - * @memberof AuthApi + * @memberof LexboxApi */ public getAuthStatus(options?: any) { - return AuthApiFp(this.configuration) + return LexboxApiFp(this.configuration) .getAuthStatus(options) .then((request) => request(this.axios, this.basePath)); } /** * - * @param {AuthApiGetLexboxEntriesRequest} requestParameters Request parameters. + * @param {LexboxApiGetLexboxEntriesRequest} requestParameters Request parameters. * @param {*} [options] Override http request option. * @throws {RequiredError} - * @memberof AuthApi + * @memberof LexboxApi */ public getLexboxEntries( - requestParameters: AuthApiGetLexboxEntriesRequest, + requestParameters: LexboxApiGetLexboxEntriesRequest, options?: any ) { - return AuthApiFp(this.configuration) + return LexboxApiFp(this.configuration) .getLexboxEntries( - requestParameters.projectType, requestParameters.projectCode, + requestParameters.vernacularLang, options ) .then((request) => request(this.axios, this.basePath)); @@ -513,10 +517,10 @@ export class AuthApi extends BaseAPI { * * @param {*} [options] Override http request option. * @throws {RequiredError} - * @memberof AuthApi + * @memberof LexboxApi */ public getLexboxProjects(options?: any) { - return AuthApiFp(this.configuration) + return LexboxApiFp(this.configuration) .getLexboxProjects(options) .then((request) => request(this.axios, this.basePath)); } @@ -525,10 +529,10 @@ export class AuthApi extends BaseAPI { * * @param {*} [options] Override http request option. * @throws {RequiredError} - * @memberof AuthApi + * @memberof LexboxApi */ public logOutLexbox(options?: any) { - return AuthApiFp(this.configuration) + return LexboxApiFp(this.configuration) .logOutLexbox(options) .then((request) => request(this.axios, this.basePath)); } diff --git a/src/api/models/index.ts b/src/api/models/index.ts index 823e652603..b5cdbf1a4f 100644 --- a/src/api/models/index.ts +++ b/src/api/models/index.ts @@ -13,13 +13,7 @@ export * from "./gloss"; export * from "./gram-cat-group"; export * from "./grammatical-info"; export * from "./lexbox-auth-status"; -export * from "./lexbox-entry"; -export * from "./lexbox-part-of-speech"; export * from "./lexbox-project"; -export * from "./lexbox-rich-span"; -export * from "./lexbox-rich-string"; -export * from "./lexbox-semantic-domain"; -export * from "./lexbox-sense"; export * from "./merge-source-word"; export * from "./merge-undo-ids"; export * from "./merge-words"; diff --git a/src/api/models/lexbox-entry.ts b/src/api/models/lexbox-entry.ts deleted file mode 100644 index e8948bc31c..0000000000 --- a/src/api/models/lexbox-entry.ts +++ /dev/null @@ -1,54 +0,0 @@ -/* tslint:disable */ -/* eslint-disable */ -/** - * BackendFramework - * No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) - * - * The version of the OpenAPI document: 1.0 - * - * - * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). - * https://openapi-generator.tech - * Do not edit the class manually. - */ - -import { LexboxRichString } from "./lexbox-rich-string"; -import { LexboxSense } from "./lexbox-sense"; - -/** - * - * @export - * @interface LexboxEntry - */ -export interface LexboxEntry { - /** - * - * @type {string} - * @memberof LexboxEntry - */ - id?: string; - /** - * - * @type {{ [key: string]: string; }} - * @memberof LexboxEntry - */ - lexemeForm?: { [key: string]: string } | null; - /** - * - * @type {{ [key: string]: string; }} - * @memberof LexboxEntry - */ - citationForm?: { [key: string]: string } | null; - /** - * - * @type {{ [key: string]: LexboxRichString; }} - * @memberof LexboxEntry - */ - note?: { [key: string]: LexboxRichString } | null; - /** - * - * @type {Array} - * @memberof LexboxEntry - */ - senses?: Array | null; -} diff --git a/src/api/models/lexbox-part-of-speech.ts b/src/api/models/lexbox-part-of-speech.ts deleted file mode 100644 index 9e88a6c1bd..0000000000 --- a/src/api/models/lexbox-part-of-speech.ts +++ /dev/null @@ -1,33 +0,0 @@ -/* tslint:disable */ -/* eslint-disable */ -/** - * BackendFramework - * No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) - * - * The version of the OpenAPI document: 1.0 - * - * - * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). - * https://openapi-generator.tech - * Do not edit the class manually. - */ - -/** - * - * @export - * @interface LexboxPartOfSpeech - */ -export interface LexboxPartOfSpeech { - /** - * - * @type {string} - * @memberof LexboxPartOfSpeech - */ - id?: string; - /** - * - * @type {{ [key: string]: string; }} - * @memberof LexboxPartOfSpeech - */ - name?: { [key: string]: string } | null; -} diff --git a/src/api/models/lexbox-rich-span.ts b/src/api/models/lexbox-rich-span.ts deleted file mode 100644 index 457eb6e678..0000000000 --- a/src/api/models/lexbox-rich-span.ts +++ /dev/null @@ -1,27 +0,0 @@ -/* tslint:disable */ -/* eslint-disable */ -/** - * BackendFramework - * No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) - * - * The version of the OpenAPI document: 1.0 - * - * - * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). - * https://openapi-generator.tech - * Do not edit the class manually. - */ - -/** - * - * @export - * @interface LexboxRichSpan - */ -export interface LexboxRichSpan { - /** - * - * @type {string} - * @memberof LexboxRichSpan - */ - text?: string | null; -} diff --git a/src/api/models/lexbox-rich-string.ts b/src/api/models/lexbox-rich-string.ts deleted file mode 100644 index 1b9e7af458..0000000000 --- a/src/api/models/lexbox-rich-string.ts +++ /dev/null @@ -1,29 +0,0 @@ -/* tslint:disable */ -/* eslint-disable */ -/** - * BackendFramework - * No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) - * - * The version of the OpenAPI document: 1.0 - * - * - * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). - * https://openapi-generator.tech - * Do not edit the class manually. - */ - -import { LexboxRichSpan } from "./lexbox-rich-span"; - -/** - * - * @export - * @interface LexboxRichString - */ -export interface LexboxRichString { - /** - * - * @type {Array} - * @memberof LexboxRichString - */ - spans?: Array | null; -} diff --git a/src/api/models/lexbox-semantic-domain.ts b/src/api/models/lexbox-semantic-domain.ts deleted file mode 100644 index e0af134a45..0000000000 --- a/src/api/models/lexbox-semantic-domain.ts +++ /dev/null @@ -1,39 +0,0 @@ -/* tslint:disable */ -/* eslint-disable */ -/** - * BackendFramework - * No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) - * - * The version of the OpenAPI document: 1.0 - * - * - * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). - * https://openapi-generator.tech - * Do not edit the class manually. - */ - -/** - * - * @export - * @interface LexboxSemanticDomain - */ -export interface LexboxSemanticDomain { - /** - * - * @type {string} - * @memberof LexboxSemanticDomain - */ - id?: string; - /** - * - * @type {{ [key: string]: string; }} - * @memberof LexboxSemanticDomain - */ - name?: { [key: string]: string } | null; - /** - * - * @type {string} - * @memberof LexboxSemanticDomain - */ - code?: string | null; -} diff --git a/src/api/models/lexbox-sense.ts b/src/api/models/lexbox-sense.ts deleted file mode 100644 index 93ff0f65a9..0000000000 --- a/src/api/models/lexbox-sense.ts +++ /dev/null @@ -1,61 +0,0 @@ -/* tslint:disable */ -/* eslint-disable */ -/** - * BackendFramework - * No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) - * - * The version of the OpenAPI document: 1.0 - * - * - * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). - * https://openapi-generator.tech - * Do not edit the class manually. - */ - -import { LexboxPartOfSpeech } from "./lexbox-part-of-speech"; -import { LexboxRichString } from "./lexbox-rich-string"; -import { LexboxSemanticDomain } from "./lexbox-semantic-domain"; - -/** - * - * @export - * @interface LexboxSense - */ -export interface LexboxSense { - /** - * - * @type {string} - * @memberof LexboxSense - */ - id?: string; - /** - * - * @type {string} - * @memberof LexboxSense - */ - entryId?: string; - /** - * - * @type {{ [key: string]: string; }} - * @memberof LexboxSense - */ - gloss?: { [key: string]: string } | null; - /** - * - * @type {{ [key: string]: LexboxRichString; }} - * @memberof LexboxSense - */ - definition?: { [key: string]: LexboxRichString } | null; - /** - * - * @type {LexboxPartOfSpeech} - * @memberof LexboxSense - */ - partOfSpeech?: LexboxPartOfSpeech; - /** - * - * @type {Array} - * @memberof LexboxSense - */ - semanticDomains?: Array | null; -} diff --git a/src/backend/index.ts b/src/backend/index.ts index ffdb43b57c..93c2695a25 100644 --- a/src/backend/index.ts +++ b/src/backend/index.ts @@ -10,7 +10,6 @@ import { ChartRootData, EmailInviteStatus, LexboxAuthStatus, - LexboxEntry, LexboxProject, MergeUndoIds, MergeWords, @@ -117,11 +116,11 @@ axiosInstance.interceptors.response.use(undefined, (err: AxiosError) => { // Configured OpenAPI interfaces. const audioApi = new Api.AudioApi(config, BASE_PATH, axiosInstance); -const authApi = new Api.AuthApi(config, BASE_PATH, axiosInstance); const avatarApi = new Api.AvatarApi(config, BASE_PATH, axiosInstance); const bannerApi = new Api.BannerApi(config, BASE_PATH, axiosInstance); const emailVerifyApi = new Api.EmailVerifyApi(config, BASE_PATH, axiosInstance); const inviteApi = new Api.InviteApi(config, BASE_PATH, axiosInstance); +const lexboxApi = new Api.LexboxApi(config, BASE_PATH, axiosInstance); const liftApi = new Api.LiftApi(config, BASE_PATH, axiosInstance); const mergeApi = new Api.MergeApi(config, BASE_PATH, axiosInstance); const passwordResetApi = new Api.PasswordResetApi( @@ -183,36 +182,6 @@ export function getAudioUrl(wordId: string, fileName: string): string { return `${baseURL}/projects/${LocalStorage.getProjectId()}/words/${wordId}/audio/download/${fileName}`; } -/* AuthController.cs */ - -export async function getLexboxAuthStatus(): Promise { - return (await authApi.getAuthStatus(defaultOptions())).data; -} - -export function getLexboxLoginUrl(): string { - return `${baseURL}/auth/lexbox-login`; -} - -export async function getLexboxProjects(): Promise { - return (await authApi.getLexboxProjects(defaultOptions())).data; -} - -export async function getLexboxEntries( - projectType: string, - projectCode: string -): Promise { - return ( - await authApi.getLexboxEntries( - { projectType, projectCode }, - defaultOptions() - ) - ).data; -} - -export async function logoutLexboxUser(): Promise { - await authApi.logOutLexbox(defaultOptions()); -} - /* AvatarController.cs */ /** Uploads avatar for current user. */ @@ -297,6 +266,32 @@ export async function validateInviteToken( ).data; } +/* LexboxController.cs */ + +export async function getLexboxAuthStatus(): Promise { + return (await lexboxApi.getAuthStatus(defaultOptions())).data; +} + +export function getLexboxLoginUrl(): string { + return `${baseURL}/auth/lexbox-login`; +} + +export async function getLexboxProjects(): Promise { + return (await lexboxApi.getLexboxProjects(defaultOptions())).data; +} + +export async function getLexboxEntries( + projectCode: string, + vernacularLang: string +): Promise { + const params = { projectCode, vernacularLang }; + return (await lexboxApi.getLexboxEntries(params, defaultOptions())).data; +} + +export async function logoutLexboxUser(): Promise { + await lexboxApi.logOutLexbox(defaultOptions()); +} + /* LiftController.cs */ /** Upload a LIFT file during project creation to get vernacular ws options. */ diff --git a/src/components/ProjectScreen/CreateProject.tsx b/src/components/ProjectScreen/CreateProject.tsx index 51ebb61e5b..6448fcedef 100644 --- a/src/components/ProjectScreen/CreateProject.tsx +++ b/src/components/ProjectScreen/CreateProject.tsx @@ -224,13 +224,11 @@ export default function CreateProject(): ReactElement { await dispatch(asyncFinishProject(trimmedName, vernLang)).then(() => setSuccess(true) ); - } else if (lexboxProject?.type && lexboxProject?.code) { + } else if (lexboxProject?.code) { try { console.info( "Project entries:", - ( - await getLexboxEntries(lexboxProject.type, lexboxProject.code) - ).slice(0, 5) + await getLexboxEntries(lexboxProject.code, vernLang.bcp47) ); } catch (e) { console.error("Error fetching Lexbox entries:", e); From 3166957a17a0433408f5e3840061f1180b1a210e Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Fri, 10 Apr 2026 14:41:17 -0400 Subject: [PATCH 37/38] Extract LexboxAuthService --- .../Controllers/LexboxControllerTests.cs | 57 ++++----- Backend/Controllers/LexboxController.cs | 118 +++++------------- Backend/Interfaces/ILexboxAuthService.cs | 14 +++ Backend/Services/LexboxAuthService.cs | 78 ++++++++++++ Backend/Startup.cs | 1 + src/api/api/lexbox-api.ts | 108 ++++++++-------- src/backend/index.ts | 6 +- 7 files changed, 210 insertions(+), 172 deletions(-) create mode 100644 Backend/Interfaces/ILexboxAuthService.cs create mode 100644 Backend/Services/LexboxAuthService.cs diff --git a/Backend.Tests/Controllers/LexboxControllerTests.cs b/Backend.Tests/Controllers/LexboxControllerTests.cs index e2cfa24482..79cba011fe 100644 --- a/Backend.Tests/Controllers/LexboxControllerTests.cs +++ b/Backend.Tests/Controllers/LexboxControllerTests.cs @@ -20,13 +20,13 @@ namespace Backend.Tests.Controllers internal sealed class LexboxControllerTests : IDisposable { private PermissionServiceMock _permissionService = null!; - private LexboxController _controller = null!; + private LexboxController _lexboxController = null!; private const string UserId = "LexboxControllerTestsUserId"; public void Dispose() { - _controller?.Dispose(); + _lexboxController?.Dispose(); GC.SuppressFinalize(this); } @@ -35,33 +35,34 @@ public void Setup() { var configValues = new Dictionary { { "LexboxAuth:PostLoginRedirect", "/" } }; var configuration = new ConfigurationBuilder().AddInMemoryCollection(configValues).Build(); + var lexboxAuthService = new LexboxAuthService(configuration); var httpClient = new HttpClient(new Mock().Object); var httpClientFactory = new Mock(); httpClientFactory.Setup(x => x.CreateClient(It.IsAny())).Returns(httpClient); - _permissionService = new PermissionServiceMock(); var lexboxQueryService = new LexboxQueryService(httpClientFactory.Object); - _controller = new LexboxController(configuration, lexboxQueryService, _permissionService); + _permissionService = new PermissionServiceMock(); + _lexboxController = new LexboxController(lexboxAuthService, lexboxQueryService, _permissionService); } [Test] - public async Task GetAuthStatusUnauthorizedReturnsForbid() + public async Task TestGetAuthStatusUnauthorizedReturnsForbid() { - _controller.ControllerContext.HttpContext = PermissionServiceMock.UnauthorizedHttpContext(); + _lexboxController.ControllerContext.HttpContext = PermissionServiceMock.UnauthorizedHttpContext(); - var result = await _controller.GetAuthStatus(); + var result = await _lexboxController.GetAuthStatus(); Assert.That(result, Is.InstanceOf()); } [Test] - public async Task GetAuthStatusReturnsLexboxUserWhenLoggedIn() + public async Task TestGetAuthStatusReturnsLexboxUserWhenLoggedIn() { var claims = new List { new("sub", "lex-1"), new("name", "Lex Name"), new("user", "Lex User") }; var authResult = AuthenticateResult.Success(new AuthenticationTicket( new ClaimsPrincipal(new ClaimsIdentity(claims, "LexboxCookie")), "LexboxCookie")); - _controller.ControllerContext.HttpContext = GetAuthContext(authResult); + _lexboxController.ControllerContext.HttpContext = GetAuthContext(authResult); - var result = await _controller.GetAuthStatus() as OkObjectResult; + var result = await _lexboxController.GetAuthStatus() as OkObjectResult; Assert.That(result, Is.Not.Null); var authStatus = result.Value as LexboxAuthStatus; @@ -72,11 +73,11 @@ public async Task GetAuthStatusReturnsLexboxUserWhenLoggedIn() } [Test] - public async Task GetAuthStatusReturnsLoggedOutWhenNotAuthenticatedByLexboxCookie() + public async Task TestGetAuthStatusReturnsLoggedOutWhenNotAuthenticatedByLexboxCookie() { - _controller.ControllerContext.HttpContext = GetAuthContext(AuthenticateResult.NoResult()); + _lexboxController.ControllerContext.HttpContext = GetAuthContext(AuthenticateResult.NoResult()); - var result = await _controller.GetAuthStatus() as OkObjectResult; + var result = await _lexboxController.GetAuthStatus() as OkObjectResult; Assert.That(result, Is.Not.Null); var authStatus = result.Value as LexboxAuthStatus; @@ -87,25 +88,25 @@ public async Task GetAuthStatusReturnsLoggedOutWhenNotAuthenticatedByLexboxCooki } [Test] - public void GetAuthStatusThrowsWhenSubClaimMissing() + public void TestGetAuthStatusThrowsWhenSubClaimMissing() { var claims = new List { new("user", "Lex User") }; var authResult = AuthenticateResult.Success(new AuthenticationTicket( new ClaimsPrincipal(new ClaimsIdentity(claims, "LexboxCookie")), "LexboxCookie")); - _controller.ControllerContext.HttpContext = GetAuthContext(authResult); + _lexboxController.ControllerContext.HttpContext = GetAuthContext(authResult); - Assert.ThrowsAsync(_controller.GetAuthStatus); + Assert.ThrowsAsync(_lexboxController.GetAuthStatus); } [Test] - public async Task GetAuthStatusFallsBackToUserIdWhenDisplayNameClaimsMissing() + public async Task TestGetAuthStatusFallsBackToUserIdWhenDisplayNameClaimsMissing() { var claims = new List { new("sub", "lex-1") }; var authResult = AuthenticateResult.Success(new AuthenticationTicket( new ClaimsPrincipal(new ClaimsIdentity(claims, "LexboxCookie")), "LexboxCookie")); - _controller.ControllerContext.HttpContext = GetAuthContext(authResult); + _lexboxController.ControllerContext.HttpContext = GetAuthContext(authResult); - var result = await _controller.GetAuthStatus() as OkObjectResult; + var result = await _lexboxController.GetAuthStatus() as OkObjectResult; Assert.That(result, Is.Not.Null); var authStatus = result.Value as LexboxAuthStatus; @@ -116,14 +117,14 @@ public async Task GetAuthStatusFallsBackToUserIdWhenDisplayNameClaimsMissing() } [Test] - public async Task GetAuthStatusUsesNameClaimWhenUserClaimMissing() + public async Task TestGetAuthStatusUsesNameClaimWhenUserClaimMissing() { var claims = new List { new("sub", "lex-1"), new("name", "Lex Name") }; var authResult = AuthenticateResult.Success(new AuthenticationTicket( new ClaimsPrincipal(new ClaimsIdentity(claims, "LexboxCookie")), "LexboxCookie")); - _controller.ControllerContext.HttpContext = GetAuthContext(authResult); + _lexboxController.ControllerContext.HttpContext = GetAuthContext(authResult); - var result = await _controller.GetAuthStatus() as OkObjectResult; + var result = await _lexboxController.GetAuthStatus() as OkObjectResult; Assert.That(result, Is.Not.Null); var authStatus = result.Value as LexboxAuthStatus; @@ -134,27 +135,27 @@ public async Task GetAuthStatusUsesNameClaimWhenUserClaimMissing() } [Test] - public async Task GenerateLexboxLoginChallengesAndReturnsEmpty() + public async Task TestGenerateLoginChallengesAndReturnsEmpty() { var authService = new AuthenticationServiceMock(AuthenticateResult.NoResult()); - _controller.ControllerContext.HttpContext = GetAuthContext(authService); + _lexboxController.ControllerContext.HttpContext = GetAuthContext(authService); Assert.That(authService.ChallengeCallCount, Is.Zero); - var result = await _controller.GenerateLexboxLogin(); + var result = await _lexboxController.GenerateLogin(); Assert.That(result, Is.InstanceOf()); Assert.That(authService.ChallengeCallCount, Is.EqualTo(1)); } [Test] - public async Task LogOutLexboxReturnsNoContent() + public async Task TestLogOutReturnsNoContent() { var claims = new List { new("sub", "lex-1"), new("user", "Lex User") }; var authResult = AuthenticateResult.Success(new AuthenticationTicket( new ClaimsPrincipal(new ClaimsIdentity(claims, "LexboxCookie")), "LexboxCookie")); - _controller.ControllerContext.HttpContext = GetAuthContext(authResult); + _lexboxController.ControllerContext.HttpContext = GetAuthContext(authResult); - var result = await _controller.LogOutLexbox(); + var result = await _lexboxController.LogOut(); Assert.That(result, Is.InstanceOf()); } diff --git a/Backend/Controllers/LexboxController.cs b/Backend/Controllers/LexboxController.cs index 59f92e7cbd..799c0c43a3 100644 --- a/Backend/Controllers/LexboxController.cs +++ b/Backend/Controllers/LexboxController.cs @@ -1,35 +1,29 @@ using System; using System.Collections.Generic; -using System.Security.Claims; using System.Threading.Tasks; -using BackendFramework.Helper; using BackendFramework.Interfaces; using BackendFramework.Models; using BackendFramework.Otel; +using BackendFramework.Services; using Microsoft.AspNetCore.Authorization; -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 LexboxController(IConfiguration configuration, ILexboxQueryService lexboxQueryService, + [Route("v1/lexbox")] + public class LexboxController(ILexboxAuthService lexboxAuthService, ILexboxQueryService lexboxQueryService, IPermissionService permissionService) : Controller { - private readonly IConfiguration _configuration = configuration; + private readonly ILexboxAuthService _lexboxAuthService = lexboxAuthService; private readonly ILexboxQueryService _lexboxQueryService = lexboxQueryService; private readonly IPermissionService _permissionService = permissionService; private const string otelTagName = "otel.LexboxController"; - private const string LexboxCookieScheme = "LexboxCookie"; - private const string LexboxOidcScheme = "LexboxOidc"; - private const string PostLoginRedirectConfigKey = "LexboxAuth:PostLoginRedirect"; /// Gets authentication status for the current request. - [HttpGet("status", Name = "GetAuthStatus")] + [HttpGet("auth-status", Name = "GetAuthStatus")] [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(LexboxAuthStatus))] [ProducesResponseType(StatusCodes.Status403Forbidden)] public async Task GetAuthStatus() @@ -41,43 +35,42 @@ public async Task GetAuthStatus() return Forbid(); } - var result = await HttpContext.AuthenticateAsync(LexboxCookieScheme); - if (!result.Succeeded || result.Principal is null) - { - // Clear any stale or undecryptable cookie (e.g. after a server restart loses Data Protection keys) - if (HttpContext.Request.Cookies.ContainsKey("lexbox_auth")) - { - await HttpContext.SignOutAsync(LexboxCookieScheme); - } - return Ok(LexboxAuthStatus.LoggedOut()); - } - - return Ok(LexboxAuthStatus.LoggedIn(GetUserFromClaims(result.Principal))); + return Ok(await _lexboxAuthService.GetAuthStatus(HttpContext)); } /// Generates a redirect to Lexbox login for OIDC sign-in. - [HttpGet("lexbox-login", Name = "GenerateLexboxLogin")] + [HttpGet("login", Name = "GenerateLogin")] [ProducesResponseType(StatusCodes.Status302Found)] [ProducesResponseType(StatusCodes.Status500InternalServerError)] - public async Task GenerateLexboxLogin() + public async Task GenerateLogin() { - using var activity = OtelService.StartActivityWithTag(otelTagName, "generating Lexbox login"); + using var activity = OtelService.StartActivityWithTag(otelTagName, "generating login"); - var redirectUrl = NormalizeReturnUrl(_configuration[PostLoginRedirectConfigKey]) - ?? Domain.FrontendDomain + "/app/auth-success"; - var authProperties = new AuthenticationProperties { RedirectUri = redirectUrl }; + if (!_permissionService.IsCurrentUserAuthenticated(HttpContext)) + { + return Forbid(); + } - return await ChallengeLexboxAsync(authProperties); + try + { + await _lexboxAuthService.Challenge(HttpContext); + return new EmptyResult(); + } + catch (Exception ex) + { + return Problem(title: "Lexbox OIDC challenge failed", detail: ex.Message, + statusCode: StatusCodes.Status500InternalServerError); + } } /// Signs out the current user from Lexbox cookie and OIDC. - [HttpPost("lexbox-logout", Name = "LogOutLexbox")] + [HttpPost("logout", Name = "LogOut")] [ProducesResponseType(StatusCodes.Status204NoContent)] - public async Task LogOutLexbox() + public async Task LogOut() { using var activity = OtelService.StartActivityWithTag(otelTagName, "logging out"); - await HttpContext.SignOutAsync(LexboxCookieScheme); + await _lexboxAuthService.SignOut(HttpContext); // TODO: Consider if we also need to sign out of the OIDC scheme here. // await HttpContext.SignOutAsync(LexboxOidcScheme) @@ -87,16 +80,16 @@ public async Task LogOutLexbox() } /// Gets Lexbox projects for the signed-in Lexbox user. - [Authorize(AuthenticationSchemes = LexboxCookieScheme)] - [HttpGet("lexbox-projects", Name = "GetLexboxProjects")] + [Authorize(AuthenticationSchemes = LexboxAuthService.LexboxCookieScheme)] + [HttpGet("projects", Name = "GetProjects")] [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(List))] [ProducesResponseType(StatusCodes.Status401Unauthorized)] [ProducesResponseType(StatusCodes.Status502BadGateway)] - public async Task GetLexboxProjects() + public async Task GetProjects() { using var activity = OtelService.StartActivityWithTag(otelTagName, "getting Lexbox projects"); - var accessToken = await TryGetLexboxAccessTokenAsync(); + var accessToken = await _lexboxAuthService.TryGetAccessToken(HttpContext); if (string.IsNullOrEmpty(accessToken)) { return Unauthorized(); @@ -115,16 +108,16 @@ public async Task GetLexboxProjects() } /// Gets entries from a Lexbox project. - [Authorize(AuthenticationSchemes = LexboxCookieScheme)] - [HttpGet("lexbox-entries/{projectCode}/{vernacularLang}", Name = "GetLexboxEntries")] + [Authorize(AuthenticationSchemes = LexboxAuthService.LexboxCookieScheme)] + [HttpGet("entries/{projectCode}/{vernacularLang}", Name = "GetEntries")] [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(List))] [ProducesResponseType(StatusCodes.Status401Unauthorized)] [ProducesResponseType(StatusCodes.Status502BadGateway)] - public async Task GetLexboxEntries(string projectCode, string vernacularLang) + public async Task GetEntries(string projectCode, string vernacularLang) { using var activity = OtelService.StartActivityWithTag(otelTagName, "getting Lexbox entries"); - var accessToken = await TryGetLexboxAccessTokenAsync(); + var accessToken = await _lexboxAuthService.TryGetAccessToken(HttpContext); if (string.IsNullOrEmpty(accessToken)) { return Unauthorized(); @@ -143,48 +136,5 @@ public async Task GetLexboxEntries(string projectCode, string ver } } - private async Task TryGetLexboxAccessTokenAsync() - { - var result = await HttpContext.AuthenticateAsync(LexboxCookieScheme); - return result.Properties?.GetTokenValue("access_token"); - } - - private async Task 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(); - return string.IsNullOrEmpty(url) || !Uri.TryCreate(url, UriKind.RelativeOrAbsolute, out var uri) - ? null - : uri.ToString(); - } - - private static LexboxAuthUser GetUserFromClaims(ClaimsPrincipal principal) - { - // https://github.com/sillsdev/languageforge-lexbox/blob/develop/backend/LexCore/Auth/LexAuthConstants.cs - var userId = principal.FindFirst("sub")?.Value?.Trim(); // LexAuthConstants.IdClaimType - if (string.IsNullOrEmpty(userId)) - { - throw new InvalidOperationException("Missing required Lexbox 'sub' claim."); - } - - var displayName = principal.FindFirst("user")?.Value // LexAuthConstants.UsernameClaimType - ?? principal.FindFirst("name")?.Value; // LexAuthConstants.NameClaimType - - return new LexboxAuthUser { DisplayName = displayName ?? userId, UserId = userId }; - } - } } diff --git a/Backend/Interfaces/ILexboxAuthService.cs b/Backend/Interfaces/ILexboxAuthService.cs new file mode 100644 index 0000000000..3bde1aa493 --- /dev/null +++ b/Backend/Interfaces/ILexboxAuthService.cs @@ -0,0 +1,14 @@ +using System.Threading.Tasks; +using BackendFramework.Models; +using Microsoft.AspNetCore.Http; + +namespace BackendFramework.Interfaces +{ + public interface ILexboxAuthService + { + Task Challenge(HttpContext httpContext); + Task GetAuthStatus(HttpContext httpContext); + Task SignOut(HttpContext httpContext); + Task TryGetAccessToken(HttpContext httpContext); + } +} diff --git a/Backend/Services/LexboxAuthService.cs b/Backend/Services/LexboxAuthService.cs new file mode 100644 index 0000000000..8a6df185c4 --- /dev/null +++ b/Backend/Services/LexboxAuthService.cs @@ -0,0 +1,78 @@ +using System; +using System.Security.Claims; +using System.Threading.Tasks; +using BackendFramework.Helper; +using BackendFramework.Interfaces; +using BackendFramework.Models; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Configuration; + +namespace BackendFramework.Services +{ + public sealed class LexboxAuthService(IConfiguration configuration) : ILexboxAuthService + { + private readonly IConfiguration _configuration = configuration; + + public const string LexboxCookieScheme = "LexboxCookie"; + private const string LexboxOidcScheme = "LexboxOidc"; + private const string PostLoginRedirectConfigKey = "LexboxAuth:PostLoginRedirect"; + + public async Task Challenge(HttpContext httpContext) + { + var redirectUrl = NormalizeReturnUrl(_configuration[PostLoginRedirectConfigKey]) + ?? Domain.FrontendDomain + "/app/auth-success"; + await httpContext.ChallengeAsync(LexboxOidcScheme, new() { RedirectUri = redirectUrl }); + } + + public async Task GetAuthStatus(HttpContext httpContext) + { + var result = await httpContext.AuthenticateAsync(LexboxCookieScheme); + if (!result.Succeeded || result.Principal is null) + { + // Clear any stale or undecryptable cookie (e.g. after a server restart loses Data Protection keys) + if (httpContext.Request.Cookies.ContainsKey("lexbox_auth")) + { + await httpContext.SignOutAsync(LexboxCookieScheme); + } + return LexboxAuthStatus.LoggedOut(); + } + + return LexboxAuthStatus.LoggedIn(GetUserFromClaims(result.Principal)); + } + + public async Task SignOut(HttpContext httpContext) + { + await httpContext.SignOutAsync(LexboxCookieScheme); + } + + public async Task TryGetAccessToken(HttpContext httpContext) + { + var result = await httpContext.AuthenticateAsync(LexboxCookieScheme); + return result.Properties?.GetTokenValue("access_token"); + } + + private static string? NormalizeReturnUrl(string? url) + { + url = url?.Trim(); + return string.IsNullOrEmpty(url) || !Uri.TryCreate(url, UriKind.RelativeOrAbsolute, out var uri) + ? null + : uri.ToString(); + } + + private static LexboxAuthUser GetUserFromClaims(ClaimsPrincipal principal) + { + // https://github.com/sillsdev/languageforge-lexbox/blob/develop/backend/LexCore/Auth/LexAuthConstants.cs + var userId = principal.FindFirst("sub")?.Value?.Trim(); // LexAuthConstants.IdClaimType + if (string.IsNullOrEmpty(userId)) + { + throw new InvalidOperationException("Missing required Lexbox 'sub' claim."); + } + + var displayName = principal.FindFirst("user")?.Value // LexAuthConstants.UsernameClaimType + ?? principal.FindFirst("name")?.Value; // LexAuthConstants.NameClaimType + + return new LexboxAuthUser { DisplayName = displayName ?? userId, UserId = userId }; + } + } +} diff --git a/Backend/Startup.cs b/Backend/Startup.cs index 76bca448be..0f35d5c182 100644 --- a/Backend/Startup.cs +++ b/Backend/Startup.cs @@ -328,6 +328,7 @@ public void ConfigureServices(IServiceCollection services) services.AddTransient(); // Lexbox types + services.AddTransient(); services.AddTransient(); // Lift Service - Singleton to avoid initializing the Sldr multiple times, diff --git a/src/api/api/lexbox-api.ts b/src/api/api/lexbox-api.ts index c16e32d1de..8fc1f04770 100644 --- a/src/api/api/lexbox-api.ts +++ b/src/api/api/lexbox-api.ts @@ -55,8 +55,8 @@ export const LexboxApiAxiosParamCreator = function ( * @param {*} [options] Override http request option. * @throws {RequiredError} */ - generateLexboxLogin: async (options: any = {}): Promise => { - const localVarPath = `/v1/auth/lexbox-login`; + generateLogin: async (options: any = {}): Promise => { + const localVarPath = `/v1/lexbox/login`; // use dummy base URL string because the URL constructor only accepts absolute URLs. const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); let baseOptions; @@ -92,7 +92,7 @@ export const LexboxApiAxiosParamCreator = function ( * @throws {RequiredError} */ getAuthStatus: async (options: any = {}): Promise => { - const localVarPath = `/v1/auth/status`; + const localVarPath = `/v1/lexbox/auth-status`; // use dummy base URL string because the URL constructor only accepts absolute URLs. const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); let baseOptions; @@ -129,25 +129,21 @@ export const LexboxApiAxiosParamCreator = function ( * @param {*} [options] Override http request option. * @throws {RequiredError} */ - getLexboxEntries: async ( + getEntries: async ( projectCode: string, vernacularLang: string, options: any = {} ): Promise => { // verify required parameter 'projectCode' is not null or undefined - assertParamExists("getLexboxEntries", "projectCode", projectCode); + assertParamExists("getEntries", "projectCode", projectCode); // verify required parameter 'vernacularLang' is not null or undefined - assertParamExists("getLexboxEntries", "vernacularLang", vernacularLang); - const localVarPath = - `/v1/auth/lexbox-entries/{projectCode}/{vernacularLang}` - .replace( - `{${"projectCode"}}`, - encodeURIComponent(String(projectCode)) - ) - .replace( - `{${"vernacularLang"}}`, - encodeURIComponent(String(vernacularLang)) - ); + assertParamExists("getEntries", "vernacularLang", vernacularLang); + const localVarPath = `/v1/lexbox/entries/{projectCode}/{vernacularLang}` + .replace(`{${"projectCode"}}`, encodeURIComponent(String(projectCode))) + .replace( + `{${"vernacularLang"}}`, + encodeURIComponent(String(vernacularLang)) + ); // use dummy base URL string because the URL constructor only accepts absolute URLs. const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); let baseOptions; @@ -182,8 +178,8 @@ export const LexboxApiAxiosParamCreator = function ( * @param {*} [options] Override http request option. * @throws {RequiredError} */ - getLexboxProjects: async (options: any = {}): Promise => { - const localVarPath = `/v1/auth/lexbox-projects`; + getProjects: async (options: any = {}): Promise => { + const localVarPath = `/v1/lexbox/projects`; // use dummy base URL string because the URL constructor only accepts absolute URLs. const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); let baseOptions; @@ -218,8 +214,8 @@ export const LexboxApiAxiosParamCreator = function ( * @param {*} [options] Override http request option. * @throws {RequiredError} */ - logOutLexbox: async (options: any = {}): Promise => { - const localVarPath = `/v1/auth/lexbox-logout`; + logOut: async (options: any = {}): Promise => { + const localVarPath = `/v1/lexbox/logout`; // use dummy base URL string because the URL constructor only accepts absolute URLs. const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); let baseOptions; @@ -264,13 +260,13 @@ export const LexboxApiFp = function (configuration?: Configuration) { * @param {*} [options] Override http request option. * @throws {RequiredError} */ - async generateLexboxLogin( + async generateLogin( options?: any ): Promise< (axios?: AxiosInstance, basePath?: string) => AxiosPromise > { const localVarAxiosArgs = - await localVarAxiosParamCreator.generateLexboxLogin(options); + await localVarAxiosParamCreator.generateLogin(options); return createRequestFunction( localVarAxiosArgs, globalAxios, @@ -307,19 +303,18 @@ export const LexboxApiFp = function (configuration?: Configuration) { * @param {*} [options] Override http request option. * @throws {RequiredError} */ - async getLexboxEntries( + async getEntries( projectCode: string, vernacularLang: string, options?: any ): Promise< (axios?: AxiosInstance, basePath?: string) => AxiosPromise> > { - const localVarAxiosArgs = - await localVarAxiosParamCreator.getLexboxEntries( - projectCode, - vernacularLang, - options - ); + const localVarAxiosArgs = await localVarAxiosParamCreator.getEntries( + projectCode, + vernacularLang, + options + ); return createRequestFunction( localVarAxiosArgs, globalAxios, @@ -332,7 +327,7 @@ export const LexboxApiFp = function (configuration?: Configuration) { * @param {*} [options] Override http request option. * @throws {RequiredError} */ - async getLexboxProjects( + async getProjects( options?: any ): Promise< ( @@ -341,7 +336,7 @@ export const LexboxApiFp = function (configuration?: Configuration) { ) => AxiosPromise> > { const localVarAxiosArgs = - await localVarAxiosParamCreator.getLexboxProjects(options); + await localVarAxiosParamCreator.getProjects(options); return createRequestFunction( localVarAxiosArgs, globalAxios, @@ -354,13 +349,12 @@ export const LexboxApiFp = function (configuration?: Configuration) { * @param {*} [options] Override http request option. * @throws {RequiredError} */ - async logOutLexbox( + async logOut( options?: any ): Promise< (axios?: AxiosInstance, basePath?: string) => AxiosPromise > { - const localVarAxiosArgs = - await localVarAxiosParamCreator.logOutLexbox(options); + const localVarAxiosArgs = await localVarAxiosParamCreator.logOut(options); return createRequestFunction( localVarAxiosArgs, globalAxios, @@ -387,9 +381,9 @@ export const LexboxApiFactory = function ( * @param {*} [options] Override http request option. * @throws {RequiredError} */ - generateLexboxLogin(options?: any): AxiosPromise { + generateLogin(options?: any): AxiosPromise { return localVarFp - .generateLexboxLogin(options) + .generateLogin(options) .then((request) => request(axios, basePath)); }, /** @@ -409,13 +403,13 @@ export const LexboxApiFactory = function ( * @param {*} [options] Override http request option. * @throws {RequiredError} */ - getLexboxEntries( + getEntries( projectCode: string, vernacularLang: string, options?: any ): AxiosPromise> { return localVarFp - .getLexboxEntries(projectCode, vernacularLang, options) + .getEntries(projectCode, vernacularLang, options) .then((request) => request(axios, basePath)); }, /** @@ -423,9 +417,9 @@ export const LexboxApiFactory = function ( * @param {*} [options] Override http request option. * @throws {RequiredError} */ - getLexboxProjects(options?: any): AxiosPromise> { + getProjects(options?: any): AxiosPromise> { return localVarFp - .getLexboxProjects(options) + .getProjects(options) .then((request) => request(axios, basePath)); }, /** @@ -433,31 +427,31 @@ export const LexboxApiFactory = function ( * @param {*} [options] Override http request option. * @throws {RequiredError} */ - logOutLexbox(options?: any): AxiosPromise { + logOut(options?: any): AxiosPromise { return localVarFp - .logOutLexbox(options) + .logOut(options) .then((request) => request(axios, basePath)); }, }; }; /** - * Request parameters for getLexboxEntries operation in LexboxApi. + * Request parameters for getEntries operation in LexboxApi. * @export - * @interface LexboxApiGetLexboxEntriesRequest + * @interface LexboxApiGetEntriesRequest */ -export interface LexboxApiGetLexboxEntriesRequest { +export interface LexboxApiGetEntriesRequest { /** * * @type {string} - * @memberof LexboxApiGetLexboxEntries + * @memberof LexboxApiGetEntries */ readonly projectCode: string; /** * * @type {string} - * @memberof LexboxApiGetLexboxEntries + * @memberof LexboxApiGetEntries */ readonly vernacularLang: string; } @@ -475,9 +469,9 @@ export class LexboxApi extends BaseAPI { * @throws {RequiredError} * @memberof LexboxApi */ - public generateLexboxLogin(options?: any) { + public generateLogin(options?: any) { return LexboxApiFp(this.configuration) - .generateLexboxLogin(options) + .generateLogin(options) .then((request) => request(this.axios, this.basePath)); } @@ -495,17 +489,17 @@ export class LexboxApi extends BaseAPI { /** * - * @param {LexboxApiGetLexboxEntriesRequest} requestParameters Request parameters. + * @param {LexboxApiGetEntriesRequest} requestParameters Request parameters. * @param {*} [options] Override http request option. * @throws {RequiredError} * @memberof LexboxApi */ - public getLexboxEntries( - requestParameters: LexboxApiGetLexboxEntriesRequest, + public getEntries( + requestParameters: LexboxApiGetEntriesRequest, options?: any ) { return LexboxApiFp(this.configuration) - .getLexboxEntries( + .getEntries( requestParameters.projectCode, requestParameters.vernacularLang, options @@ -519,9 +513,9 @@ export class LexboxApi extends BaseAPI { * @throws {RequiredError} * @memberof LexboxApi */ - public getLexboxProjects(options?: any) { + public getProjects(options?: any) { return LexboxApiFp(this.configuration) - .getLexboxProjects(options) + .getProjects(options) .then((request) => request(this.axios, this.basePath)); } @@ -531,9 +525,9 @@ export class LexboxApi extends BaseAPI { * @throws {RequiredError} * @memberof LexboxApi */ - public logOutLexbox(options?: any) { + public logOut(options?: any) { return LexboxApiFp(this.configuration) - .logOutLexbox(options) + .logOut(options) .then((request) => request(this.axios, this.basePath)); } } diff --git a/src/backend/index.ts b/src/backend/index.ts index 93c2695a25..54cc2b0621 100644 --- a/src/backend/index.ts +++ b/src/backend/index.ts @@ -277,7 +277,7 @@ export function getLexboxLoginUrl(): string { } export async function getLexboxProjects(): Promise { - return (await lexboxApi.getLexboxProjects(defaultOptions())).data; + return (await lexboxApi.getProjects(defaultOptions())).data; } export async function getLexboxEntries( @@ -285,11 +285,11 @@ export async function getLexboxEntries( vernacularLang: string ): Promise { const params = { projectCode, vernacularLang }; - return (await lexboxApi.getLexboxEntries(params, defaultOptions())).data; + return (await lexboxApi.getEntries(params, defaultOptions())).data; } export async function logoutLexboxUser(): Promise { - await lexboxApi.logOutLexbox(defaultOptions()); + await lexboxApi.logOut(defaultOptions()); } /* LiftController.cs */ From d54676dc0c40fabb3166d75d45457dec6d6c3468 Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Fri, 10 Apr 2026 14:52:35 -0400 Subject: [PATCH 38/38] Disable backdrop click --- src/components/Lexbox/LexboxProjectsDialog.tsx | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/src/components/Lexbox/LexboxProjectsDialog.tsx b/src/components/Lexbox/LexboxProjectsDialog.tsx index 66df22f73e..8dfdfe30d0 100644 --- a/src/components/Lexbox/LexboxProjectsDialog.tsx +++ b/src/components/Lexbox/LexboxProjectsDialog.tsx @@ -29,13 +29,14 @@ interface LexboxProjectsDialogProps { export default function LexboxProjectsDialog( props: LexboxProjectsDialogProps ): ReactElement { - const { t } = useTranslation(); const [error, setError] = useState(); - const [isLoggedIn, setIsLoggedIn] = useState(false); + const [isLoggedIn, setIsLoggedIn] = useState(false); const [loading, setLoading] = useState(false); const [projects, setProjects] = useState([]); const [selected, setSelected] = useState(); + const { t } = useTranslation(); + const loadProjects = async (): Promise => { setLoading(true); setError(undefined); @@ -133,7 +134,16 @@ export default function LexboxProjectsDialog( }; return ( - + { + if (reason !== "backdropClick") { + props.onClose(); + } + }} + open={props.open} + > {t("Import from Lexbox")}