diff --git a/package.json b/package.json
index 2336d985..35516eaf 100644
--- a/package.json
+++ b/package.json
@@ -54,9 +54,9 @@
"lint": "prettier -c --parser typescript \"{src,__tests__,e2e}/**/*.[jt]s?(x)\"",
"lint:fix": "pnpm run lint --write",
"test:types": "tsc --build tsconfig.json",
- "test:unit": "vitest --coverage",
+ "test:unit": "cross-env GOOGLE_CLOUD_PROJECT='vue-fire-store' vitest --coverage",
"firebase:emulators": "firebase emulators:start",
- "test:dev": "vitest",
+ "test:dev": "cross-env GOOGLE_CLOUD_PROJECT='vue-fire-store' vitest",
"test": "pnpm run lint && pnpm run test:types && pnpm run build && pnpm run -C packages/nuxt build && pnpm run test:unit run",
"prepare": "simple-git-hooks"
},
@@ -71,6 +71,7 @@
],
"license": "MIT",
"dependencies": {
+ "jwt-decode": "^4.0.0",
"vue-demi": "latest"
},
"peerDependencies": {
@@ -96,6 +97,7 @@
"chalk": "^5.3.0",
"consola": "^3.2.3",
"conventional-changelog-cli": "^2.0.34",
+ "cross-env": "^7.0.3",
"enquirer": "^2.4.1",
"execa": "^8.0.1",
"firebase": "^10.8.0",
diff --git a/packages/nuxt/package.json b/packages/nuxt/package.json
index cf8e1b9d..24590538 100644
--- a/packages/nuxt/package.json
+++ b/packages/nuxt/package.json
@@ -32,8 +32,8 @@
"build": "nuxt-module-build build",
"lint": "eslint src",
"changelog": "conventional-changelog -p angular -i CHANGELOG.md -s --commit-path . -l nuxt-vuefire -r 1",
- "test": "vitest",
- "dev": "nuxi dev playground",
+ "test": "cross-env GOOGLE_CLOUD_PROJECT='vue-fire-store' vitest",
+ "dev": "cross-env GOOGLE_CLOUD_PROJECT='vue-fire-store' nuxi dev playground",
"dev:build": "nuxi build playground",
"dev:prepare": "nuxt-module-build --stub"
},
@@ -71,6 +71,7 @@
"firebase-admin": "^12.0.0",
"firebase-functions": "^4.7.0",
"nuxt": "^3.10.3",
+ "playwright-core": "^1.43.1",
"vuefire": "workspace:*"
}
}
diff --git a/packages/nuxt/playground/components/ServerOnlyPre.vue b/packages/nuxt/playground/components/ServerOnlyPre.vue
new file mode 100644
index 00000000..c2c3a31f
--- /dev/null
+++ b/packages/nuxt/playground/components/ServerOnlyPre.vue
@@ -0,0 +1,16 @@
+
+
+
+ {{ serverState }}
+
diff --git a/packages/nuxt/playground/pages/authentication.vue b/packages/nuxt/playground/pages/authentication.vue
index f21ce0ec..b622e553 100644
--- a/packages/nuxt/playground/pages/authentication.vue
+++ b/packages/nuxt/playground/pages/authentication.vue
@@ -45,7 +45,9 @@ watch(user, (user) => {
// new user
const email = ref('')
const password = ref('')
-function signUp() {
+const tenant = ref(null)
+
+async function signUp() {
// link to an existing anonymous account
if (user.value?.isAnonymous) {
credential = EmailAuthProvider.credential(email.value, password.value)
@@ -59,7 +61,7 @@ function signUp() {
return createUserWithEmailAndPassword(auth, email.value, password.value)
}
-function signinPopup() {
+async function signinPopup() {
return signInWithPopup(auth, googleAuthProvider).then((result) => {
const googleCredential = GoogleAuthProvider.credentialFromResult(result)
credential = googleCredential
@@ -96,37 +98,42 @@ onMounted(() => {
Auth playground
-
-
-
-
-
+
+
+
+
+
+
+
+ Tenant:
+
-
-
@@ -144,7 +151,13 @@ onMounted(() => {
Current User:
- {{ user }}
+ {{ user }}
+
+
diff --git a/packages/nuxt/src/runtime/auth/api.session-verification.ts b/packages/nuxt/src/runtime/auth/api.session-verification.ts
index 6de46341..6cb3317a 100644
--- a/packages/nuxt/src/runtime/auth/api.session-verification.ts
+++ b/packages/nuxt/src/runtime/auth/api.session-verification.ts
@@ -10,6 +10,7 @@ import {
import { ensureAdminApp } from 'vuefire/server'
import { logger } from '../logging'
import { useRuntimeConfig } from '#imports'
+import { parseTenantFromFirebaseJwt } from 'vuefire'
/**
* Setups an API endpoint to be used by the client to mint a cookie based auth session.
@@ -27,7 +28,12 @@ export default defineEventHandler(async (event) => {
},
'session-verification'
)
- const adminAuth = getAdminAuth(adminApp)
+
+ const tenant = parseTenantFromFirebaseJwt(token)
+
+ const adminAuth = tenant
+ ? getAdminAuth(adminApp).tenantManager().authForTenant(tenant)
+ : getAdminAuth(adminApp)
logger.debug(token ? 'Verifying the token' : 'Deleting the session cookie')
const verifiedIdToken = token ? await adminAuth.verifyIdToken(token) : null
diff --git a/packages/nuxt/src/runtime/auth/plugin-authenticate-user.server.ts b/packages/nuxt/src/runtime/auth/plugin-authenticate-user.server.ts
index 5f7fdac6..4cf42d59 100644
--- a/packages/nuxt/src/runtime/auth/plugin-authenticate-user.server.ts
+++ b/packages/nuxt/src/runtime/auth/plugin-authenticate-user.server.ts
@@ -17,7 +17,6 @@ export default defineNuxtPlugin(async (nuxtApp) => {
const event = useRequestEvent()
const firebaseApp = nuxtApp.$firebaseApp as FirebaseApp
const firebaseAdminApp = nuxtApp.$firebaseAdminApp as AdminApp
- const adminAuth = getAdminAuth(firebaseAdminApp)
const auth = nuxtApp.$firebaseAuth as Auth
const decodedToken = nuxtApp[
@@ -27,10 +26,17 @@ export default defineNuxtPlugin(async (nuxtApp) => {
const uid = decodedToken?.uid
+ const tenant = decodedToken?.firebase?.tenant
+
+ const adminAuth = tenant
+ ? getAdminAuth(firebaseAdminApp).tenantManager().authForTenant(tenant)
+ : getAdminAuth(firebaseAdminApp)
+
// this is also undefined if the user hasn't enabled the session cookie option
if (uid) {
// reauthenticate if the user is not the same (e.g. invalidated)
- if (auth.currentUser?.uid !== uid) {
+ // OR multi tenancy is used, otherwise tenantId won't be present in SSR accessToken
+ if (auth.currentUser?.uid !== uid || tenant) {
const customToken = await adminAuth
.createCustomToken(uid)
.catch((err) => {
@@ -40,6 +46,8 @@ export default defineNuxtPlugin(async (nuxtApp) => {
// console.timeLog('token', `got token for ${user.uid}`)
if (customToken) {
logger.debug('Signing in with custom token')
+ // Update firebase/auth tenantId to ensure it is set during SSR
+ auth.tenantId = tenant ?? null
// TODO: allow user to handle error?
await signInWithCustomToken(auth, customToken)
// console.timeLog('token', `signed in with token for ${user.uid}`)
diff --git a/packages/nuxt/src/runtime/auth/plugin-user-token.server.ts b/packages/nuxt/src/runtime/auth/plugin-user-token.server.ts
index 5d8d56fd..07f602b1 100644
--- a/packages/nuxt/src/runtime/auth/plugin-user-token.server.ts
+++ b/packages/nuxt/src/runtime/auth/plugin-user-token.server.ts
@@ -12,7 +12,7 @@ export default defineNuxtPlugin(async (nuxtApp) => {
const adminApp = nuxtApp.$firebaseAdminApp as AdminApp
const decodedToken = await decodeSessionCookie(
- getCookie(event, AUTH_COOKIE_NAME),
+ event && getCookie(event, AUTH_COOKIE_NAME),
adminApp
)
diff --git a/packages/nuxt/tests/auth.spec.ts b/packages/nuxt/tests/auth.spec.ts
new file mode 100644
index 00000000..70e227f1
--- /dev/null
+++ b/packages/nuxt/tests/auth.spec.ts
@@ -0,0 +1,138 @@
+import { describe, expect, it } from 'vitest'
+import { createPage, setup } from '@nuxt/test-utils/e2e'
+import { createResolver } from '@nuxt/kit'
+
+const { resolve } = createResolver(import.meta.url)
+
+await setup({
+ rootDir: resolve('../playground'),
+ build: false,
+ server: true,
+ browser: true,
+ dev: true,
+
+ browserOptions: {
+ type: 'chromium',
+ launch: {
+ headless: true,
+ },
+ },
+})
+
+describe('auth/multi-tenancy', async () => {
+ it('should create a default tenant token if no tenant is specified', async () => {
+ const page = await createPage('/authentication')
+
+ // 1. Sign out, clear tenant to start clean
+ await page.getByTestId('sign-out').click()
+ await page.getByTestId('tenant').clear()
+
+ // 2. Ensure test account exists
+ const signupResponse = page.waitForResponse((r) =>
+ r.url().includes('accounts:signUp')
+ )
+ await page.getByTestId('email-signup').fill('test@test.com')
+ await page.getByTestId('password-signup').fill('testtest')
+ await page.getByTestId('submit-signup').click()
+ await signupResponse
+
+ // 3. Log in with test account, check tenant
+ // Call to sign in is 'accounts:signInWithPassword', but we need __session call to get user info
+ const signinResponse = page.waitForResponse((r) =>
+ r.url().includes('/api/__session')
+ )
+ await page.getByTestId('email-signin').fill('test@test.com')
+ await page.getByTestId('password-signin').fill('testtest')
+ await page.getByTestId('submit-signin').click()
+ await signinResponse
+
+ // 4. Assert user does in fact not have a tenant id
+ const userData = await page.getByTestId('user-data-client').textContent()
+
+ expect(userData).toBeTruthy()
+ if (!userData) return
+
+ const user = JSON.parse(userData)
+ expect(user.tenantId).toBeUndefined()
+ })
+
+ it('should create token with tenantId if tenant name is specified', async () => {
+ const page = await createPage('/authentication')
+ const tenantName = 'tenant A'
+
+ // 1. Sign out, clear tenant to start clean
+ await page.getByTestId('sign-out').click()
+ await page.getByTestId('tenant').clear()
+ await page.getByTestId('tenant').fill(tenantName)
+
+ // 2. Ensure test account exists
+ const signupResponse = page.waitForResponse((r) =>
+ r.url().includes('accounts:signUp')
+ )
+ await page.getByTestId('email-signup').fill('test@test.com')
+ await page.getByTestId('password-signup').fill('testtest')
+ await page.getByTestId('submit-signup').click()
+ await signupResponse
+
+ // 3. Log in with test account, check tenant
+ // Call to sign in is 'accounts:signInWithPassword', but we need __session call to get user info
+ const signinResponse = page.waitForResponse((r) =>
+ r.url().includes('/api/__session')
+ )
+ await page.getByTestId('email-signin').fill('test@test.com')
+ await page.getByTestId('password-signin').fill('testtest')
+ await page.getByTestId('submit-signin').click()
+ await signinResponse
+
+ // 4. Assert user does in fact not have a tenant id
+ const userData = await page.getByTestId('user-data-client').textContent()
+
+ expect(userData).toBeTruthy()
+ if (!userData) return
+
+ const user = JSON.parse(userData)
+ expect(user.tenantId).toEqual(tenantName)
+ })
+
+ it('should return tenantId in server render', async () => {
+ const page = await createPage('/authentication')
+ const tenantName = 'tenant A'
+
+ // 1. Sign out, clear tenant to start clean
+ await page.getByTestId('sign-out').click()
+ await page.getByTestId('tenant').clear()
+ await page.getByTestId('tenant').fill(tenantName)
+
+ // 2. Ensure test account exists
+ const signupResponse = page.waitForResponse((r) =>
+ r.url().includes('accounts:signUp')
+ )
+ await page.getByTestId('email-signup').fill('test@test.com')
+ await page.getByTestId('password-signup').fill('testtest')
+ await page.getByTestId('submit-signup').click()
+ await signupResponse
+
+ // 3. Log in with test account, check tenant
+ // Call to sign in is 'accounts:signInWithPassword', but we need __session call to get user info
+ const signinResponse = page.waitForResponse((r) =>
+ r.url().includes('/api/__session')
+ )
+ await page.getByTestId('email-signin').fill('test@test.com')
+ await page.getByTestId('password-signin').fill('testtest')
+ await page.getByTestId('submit-signin').click()
+ await signinResponse
+
+ // 4. Reload the page to trigger server render
+ await page.reload({ waitUntil: 'domcontentloaded' })
+
+ const serverUserData = await page
+ .getByTestId('user-data-server')
+ .textContent()
+
+ expect(serverUserData).toBeTruthy()
+ if (!serverUserData) return
+
+ const serverUser = JSON.parse(serverUserData)
+ expect(serverUser.tenantId).toEqual(tenantName)
+ })
+})
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 192a5a7a..f03a8323 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -8,6 +8,9 @@ importers:
.:
dependencies:
+ jwt-decode:
+ specifier: ^4.0.0
+ version: 4.0.0
vue-demi:
specifier: latest
version: 0.14.7(vue@3.4.19)
@@ -30,6 +33,9 @@ importers:
conventional-changelog-cli:
specifier: ^2.0.34
version: 2.2.2
+ cross-env:
+ specifier: ^7.0.3
+ version: 7.0.3
enquirer:
specifier: ^2.4.1
version: 2.4.1
@@ -83,7 +89,7 @@ importers:
version: 2.0.0(typescript@5.3.3)
vitepress:
specifier: 1.0.0-rc.44
- version: 1.0.0-rc.44(@algolia/client-search@4.22.1)(search-insights@2.13.0)(typescript@5.3.3)
+ version: 1.0.0-rc.44(@algolia/client-search@4.22.1)(jwt-decode@4.0.0)(search-insights@2.13.0)(typescript@5.3.3)
vitest:
specifier: ^1.3.1
version: 1.3.1(happy-dom@13.4.1)
@@ -117,7 +123,7 @@ importers:
version: 3.10.3(rollup@3.29.4)
'@nuxt/test-utils':
specifier: ^3.11.0
- version: 3.11.0(@vue/test-utils@2.4.4)(h3@1.10.2)(happy-dom@13.4.1)(rollup@3.29.4)(vite@5.1.4)(vitest@1.3.1)(vue-router@4.3.0)(vue@3.4.19)
+ version: 3.11.0(@vue/test-utils@2.4.4)(h3@1.10.2)(happy-dom@13.4.1)(playwright-core@1.43.1)(rollup@3.29.4)(vite@5.1.4)(vitest@1.3.1)(vue-router@4.3.0)(vue@3.4.19)
eslint:
specifier: ^8.56.0
version: 8.56.0
@@ -133,6 +139,9 @@ importers:
nuxt:
specifier: ^3.10.3
version: 3.10.3(eslint@8.56.0)(rollup@3.29.4)(typescript@5.3.3)(vite@5.1.4)
+ playwright-core:
+ specifier: ^1.43.1
+ version: 1.43.1
vuefire:
specifier: workspace:*
version: link:../..
@@ -2312,7 +2321,7 @@ packages:
- supports-color
dev: true
- /@nuxt/test-utils@3.11.0(@vue/test-utils@2.4.4)(h3@1.10.2)(happy-dom@13.4.1)(rollup@3.29.4)(vite@5.1.4)(vitest@1.3.1)(vue-router@4.3.0)(vue@3.4.19):
+ /@nuxt/test-utils@3.11.0(@vue/test-utils@2.4.4)(h3@1.10.2)(happy-dom@13.4.1)(playwright-core@1.43.1)(rollup@3.29.4)(vite@5.1.4)(vitest@1.3.1)(vue-router@4.3.0)(vue@3.4.19):
resolution: {integrity: sha512-9ovgpQZkZpVg/MhYVVn2169WjH/IL0XUqwGryTa/lkx0/BCi1LMVEp3HTPkmt4qbRcxitO+kL4vFqqrFGVaSVg==}
engines: {node: ^14.18.0 || >=16.10.0}
peerDependencies:
@@ -2368,6 +2377,7 @@ packages:
ofetch: 1.3.3
pathe: 1.1.2
perfect-debounce: 1.0.0
+ playwright-core: 1.43.1
radix3: 1.1.0
scule: 1.3.0
std-env: 3.7.0
@@ -2376,7 +2386,7 @@ packages:
unplugin: 1.7.1
vite: 5.1.4(@types/node@20.11.20)
vitest: 1.3.1(happy-dom@13.4.1)
- vitest-environment-nuxt: 1.0.0(@vue/test-utils@2.4.4)(h3@1.10.2)(happy-dom@13.4.1)(rollup@3.29.4)(vite@5.1.4)(vitest@1.3.1)(vue-router@4.3.0)(vue@3.4.19)
+ vitest-environment-nuxt: 1.0.0(@vue/test-utils@2.4.4)(h3@1.10.2)(happy-dom@13.4.1)(playwright-core@1.43.1)(rollup@3.29.4)(vite@5.1.4)(vitest@1.3.1)(vue-router@4.3.0)(vue@3.4.19)
vue: 3.4.19(typescript@5.3.3)
vue-router: 4.3.0(vue@3.4.19)
transitivePeerDependencies:
@@ -3775,7 +3785,7 @@ packages:
- vue
dev: true
- /@vueuse/integrations@10.8.0(focus-trap@7.5.4)(vue@3.4.19):
+ /@vueuse/integrations@10.8.0(focus-trap@7.5.4)(jwt-decode@4.0.0)(vue@3.4.19):
resolution: {integrity: sha512-sw3P/7cXOfNLQfERp7P0IJ2ODjLE2C3BGXpBQJQkS309c1jbJak9yu4EnY70WaZjkj53aeWSFU6BbHrUxXJ7SA==}
peerDependencies:
async-validator: '*'
@@ -3819,6 +3829,7 @@ packages:
'@vueuse/core': 10.8.0(vue@3.4.19)
'@vueuse/shared': 10.8.0(vue@3.4.19)
focus-trap: 7.5.4
+ jwt-decode: 4.0.0
vue-demi: 0.14.7(vue@3.4.19)
transitivePeerDependencies:
- '@vue/composition-api'
@@ -4887,6 +4898,14 @@ packages:
resolution: {integrity: sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==}
dev: true
+ /cross-env@7.0.3:
+ resolution: {integrity: sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw==}
+ engines: {node: '>=10.14', npm: '>=6', yarn: '>=1'}
+ hasBin: true
+ dependencies:
+ cross-spawn: 7.0.3
+ dev: true
+
/cross-spawn@6.0.5:
resolution: {integrity: sha512-eTVLrBSt7fjbDygz805pMnstIs2VTBNkRm0qxZd+M7A5XDdxVRWO5MxGBXZhjY4cqLYLdtrGqRf8mBPmzwSpWQ==}
engines: {node: '>=4.8'}
@@ -7279,6 +7298,10 @@ packages:
dev: true
optional: true
+ /jwt-decode@4.0.0:
+ resolution: {integrity: sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA==}
+ engines: {node: '>=18'}
+
/keyv@4.5.4:
resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==}
dependencies:
@@ -8773,6 +8796,12 @@ packages:
mlly: 1.6.0
pathe: 1.1.2
+ /playwright-core@1.43.1:
+ resolution: {integrity: sha512-EI36Mto2Vrx6VF7rm708qSnesVQKbxEWvPrfA1IPY6HgczBplDx7ENtx+K2n4kJ41sLLkuGfmb0ZLSSXlDhqPg==}
+ engines: {node: '>=16'}
+ hasBin: true
+ dev: true
+
/postcss-calc@9.0.1(postcss@8.4.35):
resolution: {integrity: sha512-TipgjGyzP5QzEhsOZUaIkeO5mKeMFpebWzRogWG/ysonUlnHcq5aJe0jOjpfzUU8PeSaBQnrE8ehR0QA5vs8PQ==}
engines: {node: ^14 || ^16 || >=18.0}
@@ -10965,7 +10994,7 @@ packages:
fsevents: 2.3.3
dev: true
- /vitepress@1.0.0-rc.44(@algolia/client-search@4.22.1)(search-insights@2.13.0)(typescript@5.3.3):
+ /vitepress@1.0.0-rc.44(@algolia/client-search@4.22.1)(jwt-decode@4.0.0)(search-insights@2.13.0)(typescript@5.3.3):
resolution: {integrity: sha512-tO5taxGI7fSpBK1D8zrZTyJJERlyU9nnt0jHSt3fywfq3VKn977Hg0wUuTkEmwXlFYwuW26+6+3xorf4nD3XvA==}
hasBin: true
peerDependencies:
@@ -10985,7 +11014,7 @@ packages:
'@vitejs/plugin-vue': 5.0.4(vite@5.1.4)(vue@3.4.19)
'@vue/devtools-api': 7.0.15(vue@3.4.19)
'@vueuse/core': 10.8.0(vue@3.4.19)
- '@vueuse/integrations': 10.8.0(focus-trap@7.5.4)(vue@3.4.19)
+ '@vueuse/integrations': 10.8.0(focus-trap@7.5.4)(jwt-decode@4.0.0)(vue@3.4.19)
focus-trap: 7.5.4
mark.js: 8.11.1
minisearch: 6.3.0
@@ -11020,10 +11049,10 @@ packages:
- universal-cookie
dev: true
- /vitest-environment-nuxt@1.0.0(@vue/test-utils@2.4.4)(h3@1.10.2)(happy-dom@13.4.1)(rollup@3.29.4)(vite@5.1.4)(vitest@1.3.1)(vue-router@4.3.0)(vue@3.4.19):
+ /vitest-environment-nuxt@1.0.0(@vue/test-utils@2.4.4)(h3@1.10.2)(happy-dom@13.4.1)(playwright-core@1.43.1)(rollup@3.29.4)(vite@5.1.4)(vitest@1.3.1)(vue-router@4.3.0)(vue@3.4.19):
resolution: {integrity: sha512-AWMO9h4HdbaFdPWZw34gALFI8gbBiOpvfbyeZwHIPfh4kWg/TwElYHvYMQ61WPUlCGaS5LebfHkaI0WPyb//Iw==}
dependencies:
- '@nuxt/test-utils': 3.11.0(@vue/test-utils@2.4.4)(h3@1.10.2)(happy-dom@13.4.1)(rollup@3.29.4)(vite@5.1.4)(vitest@1.3.1)(vue-router@4.3.0)(vue@3.4.19)
+ '@nuxt/test-utils': 3.11.0(@vue/test-utils@2.4.4)(h3@1.10.2)(happy-dom@13.4.1)(playwright-core@1.43.1)(rollup@3.29.4)(vite@5.1.4)(vitest@1.3.1)(vue-router@4.3.0)(vue@3.4.19)
transitivePeerDependencies:
- '@cucumber/cucumber'
- '@jest/globals'
diff --git a/src/auth/index.ts b/src/auth/index.ts
index bac5b1b8..68bbe2f0 100644
--- a/src/auth/index.ts
+++ b/src/auth/index.ts
@@ -14,6 +14,8 @@ import { getGlobalScope } from '../globals'
import { isClient, _Nullable } from '../shared'
import { authUserMap, setupOnAuthStateChanged } from './user'
import { type VueFireModule } from '..'
+import { jwtDecode } from 'jwt-decode'
+import type { DecodedIdToken } from 'firebase-admin/auth'
export {
useCurrentUser,
@@ -181,3 +183,25 @@ export function useFirebaseAuth(name?: string) {
}
return isClient ? inject(_VueFireAuthKey) : null
}
+
+export function parseTenantFromFirebaseJwt(
+ jwt?: string | undefined | null
+): string | null {
+ if (!jwt) return null
+
+ try {
+ const decodedToken = jwtDecode(jwt)
+ if (!decodedToken) return null
+
+ const firebase = decodedToken.firebase
+ if (!firebase) return null
+
+ return firebase.tenant ?? null
+ } catch (error) {
+ if (process.env.NODE_ENV !== 'production') {
+ console.warn('[VueFire] could not parse tenant from jwt: ', error)
+ }
+ }
+
+ return null
+}
diff --git a/src/index.ts b/src/index.ts
index abf9c7bc..68249df1 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -96,6 +96,7 @@ export {
_VueFireAuthKey,
getCurrentUser,
updateCurrentUserProfile,
+ parseTenantFromFirebaseJwt,
} from './auth'
/**
diff --git a/src/server/auth.ts b/src/server/auth.ts
index 650378fd..1164a39b 100644
--- a/src/server/auth.ts
+++ b/src/server/auth.ts
@@ -9,7 +9,7 @@ import type { App as AdminApp } from 'firebase-admin/app'
import { getAuth as getAdminAuth } from 'firebase-admin/auth'
import { logger } from './logging'
import { isFirebaseError } from './utils'
-import { _VueFireAuthKey } from '../auth'
+import { _VueFireAuthKey, parseTenantFromFirebaseJwt } from '../auth'
// MUST be named `__session` to be kept in Firebase context, therefore this name is hardcoded
// https://firebase.google.com/docs/hosting/manage-cache#using_cookies
@@ -112,9 +112,13 @@ export async function decodeSessionCookie(
adminApp: AdminApp
): Promise {
if (sessionCookie) {
- const adminAuth = getAdminAuth(adminApp)
-
try {
+ const tenant = parseTenantFromFirebaseJwt(sessionCookie)
+
+ const adminAuth = tenant
+ ? getAdminAuth(adminApp).tenantManager().authForTenant(tenant)
+ : getAdminAuth(adminApp)
+
// TODO: should we check for the revoked status of the token here?
// we await to try/catch
// return await adminAuth.verifyIdToken(token /*, checkRevoked */)
diff --git a/tests/auth/parse-tenants-from-token.spec.ts b/tests/auth/parse-tenants-from-token.spec.ts
new file mode 100644
index 00000000..a9256ad6
--- /dev/null
+++ b/tests/auth/parse-tenants-from-token.spec.ts
@@ -0,0 +1,53 @@
+import { describe, expect, it } from 'vitest'
+import { parseTenantFromFirebaseJwt } from '../../src'
+
+describe('auth/parseTenantFromFirebaseJwt', () => {
+ it('should return null if supplied jwt is null', () => {
+ const sut = null
+
+ const result = parseTenantFromFirebaseJwt(sut)
+
+ expect(result).toBeNull()
+ })
+
+ it('should return null if supplied jwt is an empty string', () => {
+ const sut = ''
+
+ const result = parseTenantFromFirebaseJwt(sut)
+
+ expect(result).toBeNull()
+ })
+
+ it('should return null if supplied jwt is an invalid jwt', () => {
+ const sut = 'a.b.c'
+
+ const result = parseTenantFromFirebaseJwt(sut)
+
+ expect(result).toBeNull()
+ })
+
+ it('should return null if supplied jwt does not have the right structure', () => {
+ // {
+ // "tenant": "test"
+ // }
+ const sut = 'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ0ZW5hbnQiOiJ0ZXN0In0'
+
+ const result = parseTenantFromFirebaseJwt(sut)
+
+ expect(result).toBeNull()
+ })
+
+ it('should return payload tenant if supplied jwt is valid but has no signature', () => {
+ // {
+ // "firebase": {
+ // "tenant": "test"
+ // }
+ // }
+ const sut =
+ 'eyJ0eXAiOiJKV1QiLCJhbGciOiJub25lIn0.eyJmaXJlYmFzZSI6eyJ0ZW5hbnQiOiJ0ZXN0In19'
+
+ const result = parseTenantFromFirebaseJwt(sut)
+
+ expect(result).toEqual('test')
+ })
+})