Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
Show all changes
64 commits
Select commit Hold shift + click to select a range
db68ed9
Add multi-tenancy support for WebAuthn authentication
leifj Jan 16, 2026
9ea2f6b
chore: fix trailing whitespace in tenant files
leifj Jan 18, 2026
3610dd3
fix: address PR review comments
leifj Jan 19, 2026
de9de06
feat: navigate to tenant-scoped path after login
leifj Jan 20, 2026
fa88404
feat: enforce tenant-aware routing in PrivateRoute
leifj Jan 20, 2026
c12f8c9
fix: prevent TenantProvider from overwriting authenticated user's tenant
leifj Jan 20, 2026
711d944
feat: make all navigation paths tenant-aware
leifj Jan 20, 2026
ed8c5fb
feat: redirect /default/* routes to root paths for cleaner URLs
leifj Jan 23, 2026
b93517a
fix: extract tenant from passkey userHandle for tenant-discovering login
leifj Jan 27, 2026
aa88a1e
fix: use tenant from cached user for login-webauthn-begin
leifj Jan 27, 2026
e3e23e7
Handle tenant discovery during login when no cached user exists
leifj Jan 27, 2026
2094603
Fix tenant login issues for cached and non-cached users
leifj Jan 28, 2026
d75388e
fix(webauthn): restore allowCredentials for all login flows
leifj Jan 28, 2026
468420c
feat: Pass tenant ID to loginWebauthn for tenant-scoped authentication
leifj Jan 28, 2026
55c47e8
fix: use consistent tenant context for begin/finish in login flow
leifj Feb 3, 2026
11293a9
Improve cached user display names with tenant info
leifj Feb 5, 2026
0674610
refactor: simplify login to use single global WebAuthn API
leifj Feb 5, 2026
3f2539d
refactor: use global registration endpoint with tenantId in body
leifj Feb 5, 2026
8bac14a
refactor: use /id/ prefix for custom tenant URLs
leifj Feb 6, 2026
cb84e6e
fix: address PR review comments
leifj Feb 6, 2026
f41f39b
feat: Add tenant-aware API paths for issuers and verifiers
leifj Feb 10, 2026
1838cfb
fix: Use /t/ prefix for tenant API paths to match go-wallet-backend
leifj Feb 10, 2026
d60ae22
Switch to X-Tenant-ID header for tenant routing
leifj Feb 14, 2026
6f59c0f
Fix tenant discovery redirect to use /id/:tenantId/* route pattern
leifj Feb 14, 2026
6d709a4
fix: use buildTenantRoutePath for tenant discovery redirects
leifj Feb 16, 2026
33612eb
Fix UserId.fromUserHandle() to support v1 binary userHandle format
leifj Feb 16, 2026
84c3c5c
fix: login should redirect to current url path minus '/login'.
smncd Feb 16, 2026
8f4aff7
Merge branch 'master' into feature/multi-tenancy-support
smncd Feb 17, 2026
fc91634
Add TenantSelector component for multi-tenancy support
leifj Feb 17, 2026
c0a59d3
Improve TenantSelector UX in authenticated state
leifj Feb 17, 2026
4850658
fix: clean up unused imports and consts
smncd Feb 18, 2026
8bfca04
fix: get userHandle from keystore instead of constructing it from UUID
smncd Feb 18, 2026
0c8f5e4
style: add horizontal divider in sidebar
smncd Feb 18, 2026
ec08e2d
fix: tenant context should make url tenant id available.
smncd Feb 18, 2026
34990a4
refactor: temporarily rewrite tenant selector to html-native select e…
smncd Feb 18, 2026
347d728
feat: recreate tenant selector as popup
smncd Feb 20, 2026
daff943
feat: fallback tenant icon
smncd Feb 20, 2026
f599e01
lint: remove extra line
smncd Feb 20, 2026
b6be932
feat: allow custom button element in tenant switcher
smncd Feb 20, 2026
7dba41b
refactor: clean up icon
smncd Feb 20, 2026
b2703f4
feat: show tenant selector if there is some tenant that is not the cu…
smncd Feb 20, 2026
53cfc80
fix: detect if user is on correct tenant before navigation. if on wro…
smncd Feb 20, 2026
4072e8d
fix: add missing matchesTenantFromUrl() helper
smncd Feb 20, 2026
dfe5bb7
fix: restore hr
smncd Feb 20, 2026
bad1167
style: don't change border based on selected state
smncd Feb 23, 2026
33dfc6a
fix: replace hard-coded tenant path prefixes
smncd Feb 23, 2026
d083327
refactor: tenant selector should list currently selected and other av…
smncd Feb 23, 2026
5cbb782
refactor: use buildTenantRoutePath() instead of manually creating tar…
smncd Feb 23, 2026
538e841
refactor: direct imports rather than React.*
smncd Feb 23, 2026
5d6b1cb
refactor: condense PrivateRoute checking if url and stored tenantIds …
smncd Feb 23, 2026
873e78d
fix: don't exclude users without a defined tenant id in filtering of …
smncd Feb 23, 2026
2397f5f
chore: run translations coverage
smncd Feb 23, 2026
cd46559
chore: remove tenant switcher proposal since it's implemented
smncd Feb 23, 2026
76fe391
fix: restore not-found route to tenant-scoped route group
smncd Feb 24, 2026
8bae441
fix: NotFound page should do full reload if stored tenant id does not…
smncd Feb 24, 2026
736bf33
fix: re-add tenant selector trigger id
smncd Feb 27, 2026
b6d9970
fix: always use url tenant ID on signup
smncd Feb 27, 2026
9c8ea9e
fix: explicitly set tenant id header to make sure url tenant id is used
smncd Feb 27, 2026
667e25b
chore: remove unused utils
smncd Feb 27, 2026
e244201
refactor: remove outdated handling of 'tenantDiscovered' backend error
smncd Feb 27, 2026
48af122
fix: add X-Tenant-ID header to unauthenticated routes for infrastruct…
leifj Mar 6, 2026
8b3ad6f
fix: add X-Tenant-ID header to proxy requests
leifj Mar 6, 2026
d0d24b4
Update src/api/index.ts
smncd Mar 9, 2026
c761bb2
Update src/context/StatusContextProvider.tsx
smncd Mar 9, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
84 changes: 72 additions & 12 deletions src/App.jsx
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We could consider exploring react-router's data mode as a replacement for the current react-router-dom, their loader and action capabilities could be interesting for taking things out of JSX.
https://reactrouter.com/start/modes#data

Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import Spinner from './components/Shared/Spinner';

import UpdateNotification from './components/Notifications/UpdateNotification';
import CredentialDetails from './pages/Home/CredentialDetails';
import { TenantProvider } from './lib/TenantContext';

const lazyWithDelay = (importFunction, delay = 1000) => {
return React.lazy(() =>
Expand Down Expand Up @@ -36,6 +37,47 @@ const Login = lazyWithDelay(() => import('./pages/Login/Login'), 400);
const LoginState = lazyWithDelay(() => import('./pages/Login/LoginState'), 400);
const NotFound = lazyWithDelay(() => import('./pages/NotFound/NotFound'), 400);

/**
* Protected routes layout - wraps authenticated content with Layout and transitions.
* Used for both global routes (/) and tenant-scoped routes (/:tenantId/).
*/
const ProtectedLayout = () => {
const location = useLocation();
return (
<PrivateRoute>
<Layout>
<Suspense fallback={<Spinner size='small' />}>
<FadeInContentTransition appear reanimateKey={location.pathname}>
<NotificationOfflineWarning />
<Outlet />
</FadeInContentTransition>
</Suspense>
</Layout>
</PrivateRoute>
);
};

/**
* Routes that require authentication.
* These are the same for both global and tenant-scoped contexts.
*/
const AuthenticatedRoutes = () => (
<>
<Route path="settings" element={<Settings />} />
<Route index element={<Home />} />
<Route path="credential/:batchId" element={<Credential />} />
<Route path="credential/:batchId/history" element={<CredentialHistory />} />
<Route path="credential/:batchId/details" element={<CredentialDetails />} />
<Route path="history" element={<History />} />
<Route path="pending" element={<Pending />} />
<Route path="history/:transactionId" element={<HistoryDetail />} />
<Route path="add" element={<AddCredentials />} />
<Route path="send" element={<SendCredentials />} />
<Route path="verification/result" element={<VerificationResult />} />
<Route path="cb/*" element={<Home />} />
</>
);

function App() {
const location = useLocation();
return (
Expand All @@ -44,18 +86,36 @@ function App() {
<Suspense fallback={<Spinner />}>
<UpdateNotification />
<Routes>
<Route element={
<PrivateRoute>
<Layout>
<Suspense fallback={<Spinner size='small' />}>
<FadeInContentTransition appear reanimateKey={location.pathname}>
<NotificationOfflineWarning />
<Outlet />
</FadeInContentTransition>
</Suspense>
</Layout>
</PrivateRoute>
}>
{/*
* Tenant-scoped routes (/:tenantId/*)
* These routes extract the tenant ID from the URL path and provide it
* via TenantContext. Used for multi-tenant deployments where users
* access the wallet via tenant-specific URLs like /acme-corp/login.
*/}
<Route path="/:tenantId/*" element={<TenantProvider><Outlet /></TenantProvider>}>
{/* Tenant-scoped protected routes */}
<Route element={<ProtectedLayout />}>
{AuthenticatedRoutes()}
</Route>
{/* Tenant-scoped public routes */}
<Route element={
<FadeInContentTransition reanimateKey={location.pathname}>
<Outlet />
</FadeInContentTransition>
}>
<Route path="login" element={<Login />} />
<Route path="login-state" element={<LoginState />} />
</Route>
</Route>

{/*
* Global routes (no tenant prefix)
* These routes are used for:
* 1. Single-tenant deployments (backward compatible)
* 2. Global login page where tenant is discovered from passkey
* 3. Returning users who already have passkeys
*/}
<Route element={<ProtectedLayout />}>
<Route path="/settings" element={<Settings />} />
<Route path="/" element={<Home />} />
<Route path="/credential/:batchId" element={<Credential />} />
Expand Down
43 changes: 40 additions & 3 deletions src/api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { UseStorageHandle, useClearStorages, useLocalStorage, useSessionStorage
import { addItem, getItem, EXCLUDED_INDEXEDDB_PATHS } from '../indexedDB';
import { loginWebAuthnBeginOffline } from './LocalAuthentication';
import { withAuthenticatorAttachmentFromHints, withHintsFromAllowCredentials } from '@/util-webauthn';
import { getStoredTenant, setStoredTenant, clearStoredTenant, buildTenantApiPath } from '../lib/tenant';

const walletBackendUrl = config.BACKEND_URL;

Expand Down Expand Up @@ -73,6 +74,7 @@ export interface BackendApi {
promptForPrfRetry: () => Promise<boolean | AbortSignal>,
webauthnHints: string[],
retryFrom?: SignupWebauthnRetryParams,
tenantId?: string,
): Promise<Result<void, SignupWebauthnError>>,
updatePrivateData(newPrivateData: EncryptedContainer): Promise<void>,
updatePrivateDataEtag(resp: AxiosResponse): AxiosResponse,
Expand All @@ -94,6 +96,11 @@ export interface BackendApi {
| 'passkeyLoginFailedServerError'
| 'x-private-data-etag'
>>;

/** Get the current tenant ID (from session storage) */
getTenantId(): string | undefined,
/** Set the current tenant ID (stored in session storage) */
setTenantId(tenantId: string): void,
}

export function useApi(isOnlineProp: boolean = true): BackendApi {
Expand Down Expand Up @@ -339,6 +346,7 @@ export function useApi(isOnlineProp: boolean = true): BackendApi {
const clearSession = useCallback((): void => {
clearSessionStorage();
removePrivateDataEtag();
clearStoredTenant(); // Clear tenant on logout
events.dispatchEvent(new CustomEvent<ClearSessionEvent>(CLEAR_SESSION_EVENT));
}, [clearSessionStorage, removePrivateDataEtag]);

Expand Down Expand Up @@ -629,6 +637,13 @@ export function useApi(isOnlineProp: boolean = true): BackendApi {
return Err('loginKeystoreFailed');
}
}

// Store the tenant from the login response
// The backend discovers tenant from the passkey's userHandle
if (finishResp?.data?.tenantId) {
setStoredTenant(finishResp.data.tenantId);
}

await setSession(finishResp, credential, 'login');
return Ok.EMPTY;
} catch (e) {
Expand All @@ -654,10 +669,21 @@ export function useApi(isOnlineProp: boolean = true): BackendApi {
keystore: LocalStorageKeystore,
promptForPrfRetry: () => Promise<boolean | AbortSignal>,
webauthnHints: string[],
retryFrom?: SignupWebauthnRetryParams
retryFrom?: SignupWebauthnRetryParams,
tenantId?: string
): Promise<Result<void, SignupWebauthnError>> => {
// Use tenant-scoped registration endpoints if a tenant ID is provided
// This ensures the passkey's userHandle encodes the tenant for proper isolation
const storedTenant = tenantId || getStoredTenant();
const registerBeginPath = storedTenant
? buildTenantApiPath(storedTenant, '/user/register-webauthn-begin')
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would put the tenant section at a higher level for all api requests to contain the tenant in their URLs, it would help to factorize the paths creation. For instance, login begin/finish paths do not contain the tenant. Or I may be wrong and tenants may be filled in only for registration.

: '/user/register-webauthn-begin';
const registerFinishPath = storedTenant
? buildTenantApiPath(storedTenant, '/user/register-webauthn-finish')
: '/user/register-webauthn-finish';

try {
const beginData = retryFrom?.beginData || (await post('/user/register-webauthn-begin', {})).data;
const beginData = retryFrom?.beginData || (await post(registerBeginPath, {})).data;
console.log("begin", beginData);

try {
Expand Down Expand Up @@ -694,7 +720,7 @@ export function useApi(isOnlineProp: boolean = true): BackendApi {
);

try {
const finishResp = updatePrivateDataEtag(await post('/user/register-webauthn-finish', {
const finishResp = updatePrivateDataEtag(await post(registerFinishPath, {
challengeId: beginData.challengeId,
displayName: name,
privateData: serializePrivateData(privateData),
Expand All @@ -711,6 +737,13 @@ export function useApi(isOnlineProp: boolean = true): BackendApi {
clientExtensionResults: credential.getClientExtensionResults(),
},
}));

// Store the tenant from the response if available
// (tenant-scoped registration returns tenantId)
if (finishResp?.data?.tenantId) {
setStoredTenant(finishResp.data.tenantId);
}

await setSession(finishResp, credential, 'signup');
return Ok.EMPTY;

Expand Down Expand Up @@ -790,6 +823,10 @@ export function useApi(isOnlineProp: boolean = true): BackendApi {
removeEventListener,

syncPrivateData,

// Tenant utilities
getTenantId: getStoredTenant,
setTenantId: setStoredTenant,
}), [
del,
get,
Expand Down
127 changes: 127 additions & 0 deletions src/lib/TenantContext.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
/**
* TenantContext - React context for multi-tenancy support.
*
* The tenant ID can come from multiple sources (in order of precedence):
* 1. URL path parameter (/:tenantId/*) - for path-based routing
* 2. Explicitly passed tenantId prop - for components that know the tenant
* 3. SessionStorage - cached from previous login/registration
*
* Usage:
* // In App.tsx, wrap tenant-scoped routes:
* <Route path="/:tenantId/*" element={<TenantProvider><TenantRoutes /></TenantProvider>} />
*
* // In components:
* const { tenantId } = useTenant();
* api.signupWebauthn(name, keystore, ..., tenantId);
*
* See go-wallet-backend/docs/adr/011-multi-tenancy.md for full design.
*/

import React, { createContext, useContext, useEffect, useMemo, ReactNode } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { getStoredTenant, setStoredTenant, clearStoredTenant } from './tenant';

export interface TenantContextValue {
/** Current tenant ID (from URL, prop, or storage) */
tenantId: string | undefined;
/** Whether we're in a tenant-scoped context */
isMultiTenant: boolean;
/** Switch to a different tenant (navigates to new tenant's home) */
switchTenant: (newTenantId: string) => void;
/** Clear tenant context (on logout) */
clearTenant: () => void;
}

const TenantContext = createContext<TenantContextValue | null>(null);

interface TenantProviderProps {
children: ReactNode;
/** Optional explicit tenant ID (overrides URL parsing) */
tenantId?: string;
}

/**
* TenantProvider extracts tenant from URL path and provides it to children.
*
* For path-based routing, the URL structure is:
* /{tenantId}/settings
* /{tenantId}/add
* /{tenantId}/cb?code=...
*
* The provider:
* 1. Reads tenantId from URL params (useParams)
* 2. Falls back to sessionStorage if not in URL
* 3. Stores tenant in sessionStorage when found in URL
*/
export function TenantProvider({ children, tenantId: propTenantId }: TenantProviderProps) {
const navigate = useNavigate();

// Get tenant from URL path parameter
// This requires the route to be defined as /:tenantId/*
const { tenantId: urlTenantId } = useParams<{ tenantId: string }>();

// Determine effective tenant ID (prop > URL > storage)
const effectiveTenantId = propTenantId || urlTenantId || getStoredTenant();

// Sync URL tenant to storage when available
useEffect(() => {
if (urlTenantId) {
setStoredTenant(urlTenantId);
}
}, [urlTenantId]);

const switchTenant = (newTenantId: string) => {
setStoredTenant(newTenantId);
navigate(`/${newTenantId}/`);
};

const clearTenant = () => {
clearStoredTenant();
};

const value = useMemo<TenantContextValue>(() => ({
tenantId: effectiveTenantId,
isMultiTenant: !!effectiveTenantId,
switchTenant,
clearTenant,
}), [effectiveTenantId]);

return (
<TenantContext.Provider value={value}>
{children}
</TenantContext.Provider>
);
}

/**
* Hook to access tenant context.
* Must be used within a TenantProvider.
*/
export function useTenant(): TenantContextValue {
const context = useContext(TenantContext);
if (!context) {
// Return a default context for components outside TenantProvider
// This allows the app to work in single-tenant mode
return {
tenantId: getStoredTenant(),
isMultiTenant: false,
switchTenant: () => {
console.warn('switchTenant called outside TenantProvider');
},
clearTenant: clearStoredTenant,
};
}
return context;
}

/**
* Hook to get tenant ID, throwing if not available.
* Use this when tenant is required (e.g., in tenant-scoped routes).
*/
export function useRequiredTenant(): string {
const { tenantId } = useTenant();
if (!tenantId) {
throw new Error('Tenant ID is required but not available. Ensure this component is within a tenant-scoped route.');
}
return tenantId;
}
Loading