Skip to content

[Auth] Add integration tests for multi-tenancy support #5593

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

Open
wants to merge 6 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 2 additions & 0 deletions packages/auth-compat/test/helpers/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import {
getEmulatorUrl
} from '../../../auth/test/helpers/integration/settings';
import { resetEmulator } from '../../../auth/test/helpers/integration/emulator_rest_helpers';
export { createNewTenant } from '../../../auth/test/helpers/integration/emulator_rest_helpers';

export function initializeTestInstance(): void {
firebase.initializeApp(getAppConfig());
Expand All @@ -34,6 +35,7 @@ export function initializeTestInstance(): void {
}

export async function cleanUpTestInstance(): Promise<void> {
await firebase.auth().signOut();
for (const app of firebase.apps) {
await app.delete();
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
/**
* @license
* Copyright 2021 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

import firebase from '@firebase/app-compat';
import { expect, use } from 'chai';
import * as chaiAsPromised from 'chai-as-promised';
import {
createNewTenant,
initializeTestInstance,
cleanUpTestInstance
} from '../../helpers/helpers';

use(chaiAsPromised);

describe('Integration test: multi-tenant', () => {
let tenantA: string;
let tenantB: string;

beforeEach(async () => {
initializeTestInstance();
tenantA = await createNewTenant();
tenantB = await createNewTenant();
});

afterEach(async () => {
await cleanUpTestInstance();
});

it('sets the correct tenantId on the underlying user', async () => {
firebase.auth().tenantId = tenantA;
const { user } = await firebase.auth().signInAnonymously();
expect(user!.tenantId).to.eq(tenantA);
});

it('allows updateCurrentUser to be called when TID matches', async () => {
firebase.auth().tenantId = tenantA;
const { user } = await firebase.auth().signInAnonymously();
await expect(firebase.auth().updateCurrentUser(user)).not.to.be.rejected;
});

it('throws for mismatched TID', async () => {
firebase.auth().tenantId = tenantA;
const { user } = await firebase.auth().signInAnonymously();
firebase.auth().tenantId = tenantB;
await expect(firebase.auth().updateCurrentUser(user)).to.be.rejectedWith(
'auth/tenant-id-mismatch'
);
});

it('allows users to be deleted', async () => {
firebase.auth().tenantId = tenantA;
const { user } = await firebase.auth().signInAnonymously();
await user!.delete();
expect(firebase.auth().currentUser).to.be.null;
});

// The rest of the tenantId tests are in the respective flow tests
});
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,10 @@ export function legacyAuthInit() {
});
}

export async function setTenantId(tid) {
compat.auth().tenantId = tid;
}

export async function userSnap() {
return compat.auth().currentUser;
}
Expand Down
31 changes: 26 additions & 5 deletions packages/auth/test/helpers/integration/emulator_rest_helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
*/

import * as fetchImpl from 'node-fetch';
import { getAppConfig, getEmulatorUrl } from './settings';
import { API_KEY, getAppConfig, getEmulatorUrl } from './settings';

export interface VerificationSession {
code: string;
Expand Down Expand Up @@ -51,8 +51,8 @@ export async function getPhoneVerificationCodes(): Promise<
}, {} as Record<string, VerificationSession>);
}

export async function getOobCodes(): Promise<OobCodeSession[]> {
const url = buildEmulatorUrlForPath('oobCodes');
export async function getOobCodes(tenantId?: string): Promise<OobCodeSession[]> {
const url = buildEmulatorUrlForPath('oobCodes', tenantId);
const response: OobCodesResponse = await (await doFetch(url)).json();
return response.oobCodes;
}
Expand All @@ -78,10 +78,31 @@ export async function createAnonAccount(): Promise<{
return response;
}

function buildEmulatorUrlForPath(endpoint: string): string {
export async function createNewTenant(): Promise<string> {
const projectId = getAppConfig().projectId;
const url = `${getEmulatorUrl()}/identitytoolkit.googleapis.com/v2/projects/${projectId}/tenants?key=${API_KEY}`;
const request = {
name: 'tenant',
allowPasswordSignup: true,
enableEmailLinkSignin: true,
disableAuth: false,
enableAnonymousUser: true,
};
const response = await (
await doFetch(url, {
method: 'POST',
body: JSON.stringify(request),
headers: {'Content-Type': 'application/json', 'Authorization': 'Bearer owner'}
})
).json();
return response.tenantId;
}

function buildEmulatorUrlForPath(endpoint: string, tenantId?: string): string {
const emulatorBaseUrl = getEmulatorUrl();
const projectId = getAppConfig().projectId;
return `${emulatorBaseUrl}/emulator/v1/projects/${projectId}/${endpoint}`;
const tenantScope = tenantId ? `/tenants/${tenantId}` : '';
return `${emulatorBaseUrl}/emulator/v1/projects/${projectId}${tenantScope}/${endpoint}`;
}

function doFetch(url: string, request?: RequestInit): ReturnType<typeof fetch> {
Expand Down
2 changes: 2 additions & 0 deletions packages/auth/test/integration/flows/anonymous.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,8 @@ describe('Integration test: anonymous auth', () => {
expect(user.uid).to.be.a('string');
});

// Multi-tenancy for anon auth covered by multi_tenant.local.test.ts

it('second sign in on the same device yields same user', async () => {
const { user: userA } = await signInAnonymously(auth);
const { user: userB } = await signInAnonymously(auth);
Expand Down
21 changes: 21 additions & 0 deletions packages/auth/test/integration/flows/custom.local.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import {
import { FirebaseError } from '@firebase/util';
import { expect, use } from 'chai';
import * as chaiAsPromised from 'chai-as-promised';
import { createNewTenant } from '../../helpers/integration/emulator_rest_helpers';
import {
cleanUpTestInstance,
getTestInstance,
Expand Down Expand Up @@ -79,6 +80,26 @@ describe('Integration test: custom auth', () => {
expect(additionalUserInfo.isNewUser).to.be.true;
});

it('signs in with custom token in tenant project', async () => {
const tenantId = await createNewTenant();
auth.tenantId = tenantId;
const cred = await signInWithCustomToken(auth, customToken);
expect(auth.currentUser).to.eq(cred.user);
expect(cred.operationType).to.eq(OperationType.SIGN_IN);

const { user } = cred;
expect(user.isAnonymous).to.be.false;
expect(user.uid).to.eq(uid);
expect(user.tenantId).to.eq(tenantId);
expect((await user.getIdTokenResult(false)).claims.customClaim).to.eq(
'some-claim'
);
expect(user.providerId).to.eq('firebase');
const additionalUserInfo = await getAdditionalUserInfo(cred)!;
expect(additionalUserInfo.providerId).to.be.null;
expect(additionalUserInfo.isNewUser).to.be.true;
});

it('uid will overwrite existing user, joining accounts', async () => {
const { user: anonUser } = await signInAnonymously(auth);
const customCred = await signInWithCustomToken(
Expand Down
27 changes: 27 additions & 0 deletions packages/auth/test/integration/flows/email.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import {
getTestInstance,
randomEmail
} from '../../helpers/integration/helpers';
import { createNewTenant } from '../../helpers/integration/emulator_rest_helpers';

use(chaiAsPromised);

Expand Down Expand Up @@ -74,6 +75,32 @@ describe('Integration test: email/password auth', () => {
expect(additionalUserInfo.providerId).to.eq('password');
});

it('allows user to sign up in tenant project', async () => {
const tenantId = await createNewTenant();
auth.tenantId = tenantId;
const userCred = await createUserWithEmailAndPassword(
auth,
email,
'password'
);
expect(auth.currentUser).to.eq(userCred.user);
expect(userCred.operationType).to.eq(OperationType.SIGN_IN);

const user = userCred.user;
expect(user.isAnonymous).to.be.false;
expect(user.uid).to.be.a('string');
expect(user.email).to.eq(email);
expect(user.emailVerified).to.be.false;
expect(user.providerData.length).to.eq(1);
expect(user.providerData[0].providerId).to.eq('password');
expect(user.providerData[0].email).to.eq(email);
expect(user.tenantId).to.eq(tenantId);

const additionalUserInfo = getAdditionalUserInfo(userCred)!;
expect(additionalUserInfo.isNewUser).to.be.true;
expect(additionalUserInfo.providerId).to.eq('password');
});

it('errors when createUser called twice', async () => {
await createUserWithEmailAndPassword(auth, email, 'password');
await expect(
Expand Down
95 changes: 95 additions & 0 deletions packages/auth/test/integration/flows/idp.local.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import {
import { FirebaseError } from '@firebase/util';
import { expect, use } from 'chai';
import * as chaiAsPromised from 'chai-as-promised';
import { createNewTenant } from '../../helpers/integration/emulator_rest_helpers';
import {
cleanUpTestInstance,
getTestInstance,
Expand Down Expand Up @@ -89,6 +90,31 @@ describe('Integration test: headless IdP', () => {
expect(additionalUserInfo.providerId).to.eq('google.com');
});

it('signs in with an OAuth token in a tenant', async () => {
const tenantId = await createNewTenant();
auth.tenantId = tenantId;
const cred = await signInWithCredential(
auth,
GoogleAuthProvider.credential(oauthIdToken)
);
expect(auth.currentUser).to.eq(cred.user);
expect(cred.operationType).to.eq(OperationType.SIGN_IN);

// Make sure the user is setup correctly
const { user } = cred;
expect(user.isAnonymous).to.be.false;
expect(user.emailVerified).to.be.true;
expect(user.providerData.length).to.eq(1);
expect(user.providerData[0].providerId).to.eq('google.com');
expect(user.providerData[0].email).to.eq(email);
expect(user.tenantId).to.eq(tenantId);

// Make sure the additional user info is good
const additionalUserInfo = getAdditionalUserInfo(cred)!;
expect(additionalUserInfo.isNewUser).to.be.true;
expect(additionalUserInfo.providerId).to.eq('google.com');
});

it('allows the user to update profile', async () => {
const credential = GithubAuthProvider.credential(oauthIdToken);
const { user } = await signInWithCredential(auth, credential);
Expand All @@ -110,6 +136,30 @@ describe('Integration test: headless IdP', () => {
expect(auth.currentUser!.photoURL).to.eq('http://photo.test/david.png');
});

it('allows the user to update profile in a tenant', async () => {
const tenantId = await createNewTenant();
auth.tenantId = tenantId;
const credential = GithubAuthProvider.credential(oauthIdToken);
const { user } = await signInWithCredential(auth, credential);

await updateProfile(user, {
displayName: 'David Copperfield',
photoURL: 'http://photo.test/david.png'
});

// Check everything first
expect(user.displayName).to.eq('David Copperfield');
expect(user.photoURL).to.eq('http://photo.test/david.png');

await auth.signOut();

// Sign in again and double check; look at current user this time
await signInWithCredential(auth, credential);
expect(auth.currentUser!.displayName).to.eq('David Copperfield');
expect(auth.currentUser!.photoURL).to.eq('http://photo.test/david.png');
expect(auth.currentUser!.tenantId).to.eq(tenantId);
});

it('allows the user to change the email', async () => {
const credential = FacebookAuthProvider.credential(oauthIdToken);
const { user } = await signInWithCredential(auth, credential);
Expand Down Expand Up @@ -206,6 +256,51 @@ describe('Integration test: headless IdP', () => {
expect(user.providerData[0].providerId).to.eq('facebook.com');
});

it('can link with multiple idps within a tenant', async () => {
const tenantId = await createNewTenant();
auth.tenantId = tenantId;
const googleEmail = randomEmail();
const facebookEmail = randomEmail();

const googleCredential = GoogleAuthProvider.credential(
JSON.stringify({
sub: googleEmail,
email: googleEmail,
'email_verified': true
})
);

const facebookCredential = FacebookAuthProvider.credential(
JSON.stringify({
sub: facebookEmail,
email: facebookEmail
})
);

// Link and then test everything
const { user } = await signInWithCredential(auth, facebookCredential);
await linkWithCredential(user, googleCredential);
expect(user.email).to.eq(facebookEmail);
expect(user.emailVerified).to.be.false;
expect(user.providerData.length).to.eq(2);
expect(
user.providerData.find(p => p.providerId === 'google.com')!.email
).to.eq(googleEmail);
expect(
user.providerData.find(p => p.providerId === 'facebook.com')!.email
).to.eq(facebookEmail);
expect(user.tenantId).to.eq(tenantId);

// Unlink Google and check everything again
await unlink(user, ProviderId.GOOGLE);
expect(user.email).to.eq(facebookEmail);
expect(user.emailVerified).to.be.false;
expect(user.providerData.length).to.eq(1);
expect(user.providerData[0].email).to.eq(facebookEmail);
expect(user.providerData[0].providerId).to.eq('facebook.com');
expect(user.tenantId).to.eq(tenantId);
});

it('IdP account takes over unverified email', async () => {
const credential = GoogleAuthProvider.credential(oauthIdToken);
const { user: emailUser } = await createUserWithEmailAndPassword(
Expand Down
Loading