Skip to content

Add multi-tenancy support for WebAuthn authentication#993

Open
leifj wants to merge 64 commits intowwWallet:masterfrom
sirosfoundation:feature/multi-tenancy-support
Open

Add multi-tenancy support for WebAuthn authentication#993
leifj wants to merge 64 commits intowwWallet:masterfrom
sirosfoundation:feature/multi-tenancy-support

Conversation

@leifj
Copy link

@leifj leifj commented Jan 16, 2026

  • Add tenant utilities (src/lib/tenant.ts) for tenant ID storage and API path building
  • Add TenantContext (src/lib/TenantContext.tsx) for URL-based tenant extraction
  • Update API to use tenant-scoped registration endpoints (/t/{tenantId}/user/register-webauthn-*)
  • Login remains global (tenant-discovering from passkey userHandle)
  • Add tenant-scoped routes (/:tenantId/*) to App.jsx
  • Pass tenant context to signup flow in Login.tsx
  • Backward compatible with single-tenant deployments

Implements frontend support for go-wallet-backend ADR 011 multi-tenancy

- Add tenant utilities (src/lib/tenant.ts) for tenant ID storage and API path building
- Add TenantContext (src/lib/TenantContext.tsx) for URL-based tenant extraction
- Update API to use tenant-scoped registration endpoints (/t/{tenantId}/user/register-webauthn-*)
- Login remains global (tenant-discovering from passkey userHandle)
- Add tenant-scoped routes (/:tenantId/*) to App.jsx
- Pass tenant context to signup flow in Login.tsx
- Backward compatible with single-tenant deployments

Implements frontend support for go-wallet-backend ADR 011 multi-tenancy
@leifj leifj requested a review from a team as a code owner January 16, 2026 21:44
@leifj leifj marked this pull request as draft January 16, 2026 21:44
@leifj
Copy link
Author

leifj commented Jan 16, 2026

@nvoutsin @smncd @patatoid please review this

*/
export function buildTenantApiPath(tenantId: string, basePath: string, useApiPrefix: boolean = true): string {
const cleanPath = basePath.startsWith('/') ? basePath : `/${basePath}`;
if (useApiPrefix) {
Copy link
Contributor

Choose a reason for hiding this comment

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

it could be nice to use a ternary here as well

Copy link
Author

Choose a reason for hiding this comment

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

ternary?

Copy link
Member

@smncd smncd Jan 19, 2026

Choose a reason for hiding this comment

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

I think Jesse is talking about the multiple return statements. Me and Pascal also thought about this but thought of a different approach, perhaps constructing the url out of the different path elements and then returning. For example:

export function buildTenantApiPath(tenantId: string, basePath: string, useApiPrefix: boolean = true): string {
	const pathParts = [
		tenantId,
		...basePath.split('/').filter(String),
	];

	if (useApiPrefix) pathParts.unshift('t');

	return `/${pathParts.join('/')}`;
}

*
* @param tenantId - The tenant ID to scope to
* @param basePath - The base path (e.g., '/user/register-webauthn-begin')
* @param useApiPrefix - Whether to use /t/ prefix (for public tenant routes)
Copy link
Contributor

Choose a reason for hiding this comment

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

the parameter useApiPrefix does not seem to be used anywhere yet. I do not understand the distinction between public and private tenant routes.

@smncd smncd self-requested a review January 19, 2026 12:41
Copy link
Member

@smncd smncd left a comment

Choose a reason for hiding this comment

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

Trying this out with the go-wallet-backend, things seem to work, except for when I add a tenant and try to use it to sign up, e.g. http://localhost:3000/test/login. This gives me a "verification failed" error. from the webauthn finish endpoint. This is only the case when the tenant is specified in the url.

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

@smncd
Copy link
Member

smncd commented Jan 19, 2026

I got past the issue with the registering/login, but it doesn't seem to pick up the tenant from the passkey as intended. This would need to take place before the webauthn-finish request is made to the backend, no?

- Move TenantContext.tsx to src/context/ per project conventions
- Fix ESLint missing switchTenant dependency using useCallback
- Remove unused useApiPrefix parameter from buildTenantApiPath
- Fix route duplication by reusing authenticatedRoutes() helper
- Update all import paths after moving TenantContext
@leifj
Copy link
Author

leifj commented Jan 19, 2026

Pushed a bunch of fixes to the comments just now. The issue identified by @smncd was likely due to a bug on the backend. This was just fixed in go-wallet-backend#main. Please check if it looks better now!

@leifj leifj marked this pull request as ready for review January 19, 2026 20:37
@leifj
Copy link
Author

leifj commented Jan 19, 2026

I pushed some more fixes to the backend that clarified the security model: only users with no prefix or "default" are allowed to login to the default tenant.

@leifj
Copy link
Author

leifj commented Jan 19, 2026

The e2e tests have also been updated to cover this.

@smncd
Copy link
Member

smncd commented Jan 20, 2026

I think we need some guidance on how to properly test this @leifj, I'm still running into the same "verification failed" error when signing up when a tenant is specified in the url. It would also be useful to hear more how this is meant to work so we can better test the PR.

src/api/index.ts Outdated
// 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.

leifj added 4 commits January 20, 2026 14:56
After successful WebAuthn login, navigate to the correct path based on
the tenant returned by the backend:
- Non-default tenants: /{tenantId}/
- Default tenant: /

This ensures users with tenant-scoped passkeys are directed to their
tenant's home page rather than always going to the root path.

Added helpers to tenant.ts:
- DEFAULT_TENANT_ID constant ('default')
- isDefaultTenant() - checks if tenant is the default
- buildTenantRoutePath() - builds frontend route path for tenant
- Redirect authenticated users to correct tenant path on URL mismatch
- Scenario 1: Default tenant user at /default/* → redirect to /
- Scenario 2: Non-default tenant user at / → redirect to /{tenantId}/
- Scenario 3: User at wrong tenant /B/ → redirect to /{correctTenantId}/
- Scenario 4: Default tenant user at /other-tenant/ → redirect to /

This ensures:
- Users always see URLs matching their authenticated tenant
- Default tenant users never see 'default' in the URL path
- Cross-tenant URL access is prevented for authenticated users
When an authenticated user navigates to a different tenant's URL path
(e.g., default tenant user accessing /{tenantId}/), the TenantProvider
was incorrectly overwriting their stored tenant ID with the URL tenant.

This fix ensures that:
1. If a user is already authenticated (has stored tenant), keep it
2. Only sync URL tenant to storage for unauthenticated users
3. PrivateRoute will redirect authenticated users to their correct tenant

This completes the tenant isolation by preventing navigation-based
tenant switching for authenticated users.
- Add buildPath helper to TenantContext for building tenant-aware paths
- Update Sidebar.jsx and BottomNav.jsx navigation items to use buildPath
- Update Home.jsx navigation to use buildPath for add, pending, and credential pages
- Update Credential.jsx to use buildPath for history and details navigation
- Update CredentialLayout.jsx breadcrumb link to use buildPath
- Update HistoryList.jsx mobile navigation to use buildPath
- Update PinInput.jsx cancel navigation to use buildPath
- Update NotFound.jsx home button to use buildPath
- Update LoginState.jsx redirects and cancel button to use buildPath
- Update OpenID4VCI.ts transaction completion redirect to use buildPath
- Update PrivateRoute.tsx to preserve tenant path in login redirects

This ensures non-default tenant users stay on their tenant-scoped URLs
without unnecessary redirects to the default tenant paths.
Copy link
Member

@smncd smncd left a comment

Choose a reason for hiding this comment

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

Making progress! Looking better, but one issue is now that on the /settings page (with or without tenantid in the path), I get these fatal errors, and no rendering of the settings page.

It seems like this is due to the backend returning the following from http://localhost:8080/user/session/account-info in the go-wallet-backend:

{
    "uuid": "<REDACTED>",
    "displayName": "<REDACTED>",
    "hasPassword": false,
    "settings": {},
    "webauthnCredentials": [
        {
            "id": "<REDACTED>",
            "credentialId": null, // <-- the frontend requires this to be typeof BufferSource
            "prfCapable": false,
            "createTime": "2026-01-20T15:02:53.402824954+01:00"
        }
    ]
}

The frontend expects this to be a BufferSource. The failure originates here:

export function toU8(b: BufferSource) {
if (b instanceof ArrayBuffer) {
return new Uint8Array(b);
} else {
return new Uint8Array(b.buffer);
}
}

Comment on lines +116 to +145
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
const storedTenant = getStoredTenant();
return {
tenantId: storedTenant,
isMultiTenant: false,
switchTenant: () => {
console.warn('switchTenant called outside TenantProvider');
},
clearTenant: clearStoredTenant,
buildPath: (subPath?: string) => buildTenantRoutePath(storedTenant, subPath),
};
}
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;
}
Copy link
Member

Choose a reason for hiding this comment

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

I'd argue that both of these hooks should be in a separate file in src/hooks, say src/hooks/tenants.ts.

(for reference, currently most contexts are used in components without a "wrapper hook", like useContext(CredentialsContext). I think this should be standardised in a wrapper hook for consistency, but those are a later worry)

Copy link
Member

@smncd smncd left a comment

Choose a reason for hiding this comment

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

I've now tested the PR with the node wallet-backend and it looks good! Some things I noticed is referenced in the review comments but to summarize:
If I provide a tenant ID in the url path on login or registration, this sticks, even if the tenant ID in question doesn't exist. This can be solved by some of the suggestions I made, as well as Pascal's suggestion of enforcing that all backend api paths should have the tenant ID in them.

leifj added 5 commits January 27, 2026 16:21
When a user selects a passkey for login, the tenant ID is now extracted
from the passkey's userHandle (format: tenantId:userId) and used to
determine the correct backend API path.

This ensures that regardless of which login page the user starts from
(/login or /{tenant}/login), the correct tenant-scoped backend endpoint
is used based on the passkey's embedded tenant.

Changes:
- Add extractTenantFromUserHandle() to parse tenant from userHandle
- Add buildLoginFinishPath() to determine correct login-finish API path
- Modify loginWebauthn() to use tenant-aware path based on passkey
- Store extracted tenant in session on successful login
- Add comprehensive unit tests for tenant extraction functions
The login-webauthn-begin endpoint must also be tenant-scoped for
tenant-scoped users. The tenant is now extracted from the cached user's
userHandleB64u (if available) before calling login-webauthn-begin.

Changes:
- Add buildLoginBeginPath() function for login-begin path
- Extract tenant from cachedUser.userHandleB64u for begin call
- Both begin and finish now use correct tenant-scoped paths
- Add tests for buildLoginBeginPath()
When a user has no cached user in local storage and logs in via a global
/login path with a non-default tenant passkey, the frontend now:

1. Detects the tenant mismatch after passkey selection
2. Returns a new error type 'tenantDiscovered' with the discovered tenantId
3. Callers (Login.tsx, LoginState.jsx, SyncPopup.jsx) handle this by
   redirecting to /{tenantId}/login

This ensures the second login attempt uses the correct tenant-scoped
begin/finish endpoints, which is required because the backend validates
that the challenge's tenant matches the request tenant.

The UX tradeoff is that first-time logins from /login with non-default
tenant passkeys require two passkey prompts (one to discover the tenant,
one to complete login). This is unavoidable without backend changes.
Issue 1: Cached user with tenant failed with 'Session was not initiated
as a client-side discoverable login' error.

Fix: For tenant-scoped login, do NOT include allowCredentials in the
WebAuthn request. The backend's BeginTenantLogin/FinishTenantLogin use
discoverable credential flow (BeginDiscoverableLogin/ValidateDiscoverableLogin).
We can still include PRF extension inputs (evalByCredential) without
allowCredentials - the browser will show discoverable credential picker
and evaluate PRF for the selected credential.

Issue 2: After tenant discovery redirect, login did not auto-retry.

Fix: Add ?autoRetry=true query param when redirecting to tenant login.
The WebauthnSignupLogin component now has an effect that detects this
param and automatically triggers login, providing seamless UX where the
user only needs to authenticate once with their passkey.
With the backend fix that removes UserID from FinishTenantLogin session
data, we can now include allowCredentials for tenant-scoped login as well.

This restores better UX by filtering the browser's credential picker to
show only matching passkeys, rather than showing all discoverable
credentials.
@smncd smncd assigned leifj and smncd Feb 24, 2026
@smncd smncd added the enhancement For suggesting improvements to existing features label Feb 24, 2026
Copy link
Member

@smncd smncd left a comment

Choose a reason for hiding this comment

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

An issue sometimes presents itself: when switching tenant by going to a different url and signing up for an account, the tenant ID saved in storage is the ID of the previous tenant, meaning signup happens on that tenant. We need a mechanism to handle tenant diffs on the login/signup pages. IMO, we should prefer the URL tenant ID here, rather than the stored one.

@smncd
Copy link
Member

smncd commented Feb 27, 2026

An issue sometimes presents itself: when switching tenant by going to a different url and signing up for an account, the tenant ID saved in storage is the ID of the previous tenant, meaning signup happens on that tenant. We need a mechanism to handle tenant diffs on the login/signup pages. IMO, we should prefer the URL tenant ID here, rather than the stored one.

We could consider using the URL tenant id exclusively for signups, and disregarding the stored tenant id

@smncd smncd force-pushed the feature/multi-tenancy-support branch from 0e8c64d to 9c8ea9e Compare February 27, 2026 13:16
Copy link
Member

@smncd smncd left a comment

Choose a reason for hiding this comment

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

Fixed in 9c8ea9e

smncd and others added 6 commits February 27, 2026 14:33
- since we've moved over to global login routes that pick up the tenant id automatically and redirects the user, this is no longer needed
…ure routing

- Add getTenantFromUrlPath() helper to extract tenant from URL path
- Add X-Tenant-ID header to /status endpoint in StatusContextProvider
- Add X-Tenant-ID header to login-webauthn-begin and login-webauthn-finish calls
- Add X-Tenant-ID header to register-webauthn-finish call (begin already had it)

For unauthenticated routes, tenantID is extracted from URL path (/id/{tenantId}/*).
Authenticated routes are unaffected as they already get X-Tenant-ID from stored session.
Add X-Tenant-ID header to GET and POST proxy requests in HttpProxy
to match other authenticated endpoints for multi-tenancy support.
@smncd smncd closed this Mar 12, 2026
@smncd smncd deleted the feature/multi-tenancy-support branch March 12, 2026 14:24
@smncd smncd restored the feature/multi-tenancy-support branch March 12, 2026 15:34
@smncd smncd reopened this Mar 12, 2026
@smncd
Copy link
Member

smncd commented Mar 12, 2026

Ooops, not sure what that was 😓

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement For suggesting improvements to existing features

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Epic: Multi-tenancy support

4 participants