-
Notifications
You must be signed in to change notification settings - Fork 19
Add multi-tenancy support for WebAuthn authentication #993
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from 2 commits
db68ed9
9ea2f6b
3610dd3
de9de06
fa88404
c12f8c9
711d944
ed8c5fb
b93517a
aa88a1e
e3e23e7
2094603
d75388e
468420c
55c47e8
11293a9
0674610
3f2539d
8bac14a
cb84e6e
f41f39b
1838cfb
d60ae22
6f59c0f
6d709a4
33612eb
84c3c5c
8f4aff7
fc91634
c0a59d3
4850658
8bfca04
0c8f5e4
ec08e2d
34990a4
347d728
daff943
f599e01
b6be932
7dba41b
b2703f4
53cfc80
4072e8d
dfe5bb7
bad1167
33dfc6a
d083327
5cbb782
538e841
5d6b1cb
873e78d
2397f5f
cd46559
76fe391
8bae441
736bf33
b6d9970
9c8ea9e
667e25b
e244201
48af122
8b3ad6f
d0d24b4
c761bb2
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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; | ||
|
|
||
|
|
@@ -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, | ||
|
|
@@ -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 { | ||
|
|
@@ -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]); | ||
|
|
||
|
|
@@ -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); | ||
| } | ||
smncd marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
|
|
||
| await setSession(finishResp, credential, 'login'); | ||
| return Ok.EMPTY; | ||
| } catch (e) { | ||
|
|
@@ -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') | ||
|
||
| : '/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 { | ||
|
|
@@ -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), | ||
|
|
@@ -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); | ||
| } | ||
smncd marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
|
|
||
| await setSession(finishResp, credential, 'signup'); | ||
| return Ok.EMPTY; | ||
|
|
||
|
|
@@ -790,6 +823,10 @@ export function useApi(isOnlineProp: boolean = true): BackendApi { | |
| removeEventListener, | ||
|
|
||
| syncPrivateData, | ||
|
|
||
| // Tenant utilities | ||
| getTenantId: getStoredTenant, | ||
| setTenantId: setStoredTenant, | ||
| }), [ | ||
| del, | ||
| get, | ||
|
|
||
| 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]); | ||
smncd marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
|
|
||
| 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; | ||
| } | ||
There was a problem hiding this comment.
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 currentreact-router-dom, theirloaderandactioncapabilities could be interesting for taking things out of JSX.https://reactrouter.com/start/modes#data