diff --git a/.changeset/loud-feet-jump.md b/.changeset/loud-feet-jump.md new file mode 100644 index 00000000000..c24fea7298a --- /dev/null +++ b/.changeset/loud-feet-jump.md @@ -0,0 +1,7 @@ +--- +"@firebase/database": minor +"firebase": minor +"@firebase/util": minor +--- + +Add mockUserToken support for database emulator. diff --git a/common/api-review/database.api.md b/common/api-review/database.api.md index 7bdcbe08dae..ad8490ac286 100644 --- a/common/api-review/database.api.md +++ b/common/api-review/database.api.md @@ -4,9 +4,10 @@ ```ts +import { EmulatorMockTokenOptions } from '@firebase/util'; import { FirebaseApp } from '@firebase/app'; -// @public (undocumented) +// @public export function child(parent: Reference, path: string): Reference; // @public @@ -229,7 +230,9 @@ export type Unsubscribe = () => void; export function update(ref: Reference, values: object): Promise; // @public -export function useDatabaseEmulator(db: FirebaseDatabase, host: string, port: number): void; +export function useDatabaseEmulator(db: FirebaseDatabase, host: string, port: number, options?: { + mockUserToken?: EmulatorMockTokenOptions; +}): void; ``` diff --git a/packages/database/src/api/Database.ts b/packages/database/src/api/Database.ts index 0282070c616..b3c34f4c869 100644 --- a/packages/database/src/api/Database.ts +++ b/packages/database/src/api/Database.ts @@ -18,7 +18,11 @@ import { FirebaseApp } from '@firebase/app-types'; import { FirebaseService } from '@firebase/app-types/private'; -import { validateArgCount, Compat } from '@firebase/util'; +import { + validateArgCount, + Compat, + EmulatorMockTokenOptions +} from '@firebase/util'; import { FirebaseDatabase as ExpDatabase, @@ -58,9 +62,16 @@ export class Database implements FirebaseService, Compat { * * @param host - the emulator host (ex: localhost) * @param port - the emulator port (ex: 8080) + * @param options.mockUserToken - the mock auth token to use for unit testing Security Rules */ - useEmulator(host: string, port: number): void { - useDatabaseEmulator(this._delegate, host, port); + useEmulator( + host: string, + port: number, + options: { + mockUserToken?: EmulatorMockTokenOptions; + } = {} + ): void { + useDatabaseEmulator(this._delegate, host, port, options); } /** diff --git a/packages/database/src/core/AuthTokenProvider.ts b/packages/database/src/core/AuthTokenProvider.ts index 1662eea2770..0feb61975a8 100644 --- a/packages/database/src/core/AuthTokenProvider.ts +++ b/packages/database/src/core/AuthTokenProvider.ts @@ -109,20 +109,23 @@ export class FirebaseAuthTokenProvider implements AuthTokenProvider { } } -/* Auth token provider that the Admin SDK uses to connect to the Emulator. */ -export class EmulatorAdminTokenProvider implements AuthTokenProvider { - private static EMULATOR_AUTH_TOKEN = 'owner'; +/* AuthTokenProvider that supplies a constant token. Used by Admin SDK or mockUserToken with emulators. */ +export class EmulatorTokenProvider implements AuthTokenProvider { + /** A string that is treated as an admin access token by the RTDB emulator. Used by Admin SDK. */ + static OWNER = 'owner'; + + constructor(private accessToken: string) {} getToken(forceRefresh: boolean): Promise { return Promise.resolve({ - accessToken: EmulatorAdminTokenProvider.EMULATOR_AUTH_TOKEN + accessToken: this.accessToken }); } addTokenChangeListener(listener: (token: string | null) => void): void { // Invoke the listener immediately to match the behavior in Firebase Auth // (see packages/auth/src/auth.js#L1807) - listener(EmulatorAdminTokenProvider.EMULATOR_AUTH_TOKEN); + listener(this.accessToken); } removeTokenChangeListener(listener: (token: string | null) => void): void {} diff --git a/packages/database/src/exp/Database.ts b/packages/database/src/exp/Database.ts index f70d797be3e..455eb108068 100644 --- a/packages/database/src/exp/Database.ts +++ b/packages/database/src/exp/Database.ts @@ -24,11 +24,15 @@ import { } from '@firebase/app-exp'; import { FirebaseAuthInternalName } from '@firebase/auth-interop-types'; import { Provider } from '@firebase/component'; -import { getModularInstance } from '@firebase/util'; +import { + getModularInstance, + createMockUserToken, + EmulatorMockTokenOptions +} from '@firebase/util'; import { AuthTokenProvider, - EmulatorAdminTokenProvider, + EmulatorTokenProvider, FirebaseAuthTokenProvider } from '../core/AuthTokenProvider'; import { Repo, repoInterrupt, repoResume, repoStart } from '../core/Repo'; @@ -74,7 +78,8 @@ let useRestClient = false; function repoManagerApplyEmulatorSettings( repo: Repo, host: string, - port: number + port: number, + tokenProvider?: AuthTokenProvider ): void { repo.repoInfo_ = new RepoInfo( `${host}:${port}`, @@ -86,8 +91,8 @@ function repoManagerApplyEmulatorSettings( repo.repoInfo_.includeNamespaceInQueryParams ); - if (repo.repoInfo_.nodeAdmin) { - repo.authTokenProvider_ = new EmulatorAdminTokenProvider(); + if (tokenProvider) { + repo.authTokenProvider_ = tokenProvider; } } @@ -135,7 +140,7 @@ export function repoManagerDatabaseFromApp( const authTokenProvider = nodeAdmin && isEmulator - ? new EmulatorAdminTokenProvider() + ? new EmulatorTokenProvider(EmulatorTokenProvider.OWNER) : new FirebaseAuthTokenProvider(app.name, app.options, authProvider); validateUrl('Invalid Firebase Database URL', parsedUrl); @@ -286,11 +291,15 @@ export function getDatabase( * @param db - The instance to modify. * @param host - The emulator host (ex: localhost) * @param port - The emulator port (ex: 8080) + * @param options.mockUserToken - the mock auth token to use for unit testing Security Rules */ export function useDatabaseEmulator( db: FirebaseDatabase, host: string, - port: number + port: number, + options: { + mockUserToken?: EmulatorMockTokenOptions; + } = {} ): void { db = getModularInstance(db); db._checkNotDeleted('useEmulator'); @@ -299,8 +308,26 @@ export function useDatabaseEmulator( 'Cannot call useEmulator() after instance has already been initialized.' ); } + + const repo = db._repoInternal; + let tokenProvider: EmulatorTokenProvider | undefined = undefined; + if (repo.repoInfo_.nodeAdmin) { + if (options.mockUserToken) { + fatal( + 'mockUserToken is not supported by the Admin SDK. For client access with mock users, please use the "firebase" package instead of "firebase-admin".' + ); + } + tokenProvider = new EmulatorTokenProvider(EmulatorTokenProvider.OWNER); + } else if (options.mockUserToken) { + const token = createMockUserToken( + options.mockUserToken, + db.app.options.projectId + ); + tokenProvider = new EmulatorTokenProvider(token); + } + // Modify the repo to apply emulator settings - repoManagerApplyEmulatorSettings(db._repoInternal, host, port); + repoManagerApplyEmulatorSettings(repo, host, port, tokenProvider); } /** diff --git a/packages/util/index.node.ts b/packages/util/index.node.ts index a603393d987..8dace3b8e1e 100644 --- a/packages/util/index.node.ts +++ b/packages/util/index.node.ts @@ -25,6 +25,7 @@ export * from './src/crypt'; export * from './src/constants'; export * from './src/deepCopy'; export * from './src/deferred'; +export * from './src/emulator'; export * from './src/environment'; export * from './src/errors'; export * from './src/json'; diff --git a/packages/util/index.ts b/packages/util/index.ts index d2da4426c4a..00d661734b8 100644 --- a/packages/util/index.ts +++ b/packages/util/index.ts @@ -20,6 +20,7 @@ export * from './src/crypt'; export * from './src/constants'; export * from './src/deepCopy'; export * from './src/deferred'; +export * from './src/emulator'; export * from './src/environment'; export * from './src/errors'; export * from './src/json'; diff --git a/packages/util/src/emulator.ts b/packages/util/src/emulator.ts new file mode 100644 index 00000000000..6f5a9dbacf8 --- /dev/null +++ b/packages/util/src/emulator.ts @@ -0,0 +1,142 @@ +/** + * @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 { base64 } from './crypt'; + +// Firebase Auth tokens contain snake_case claims following the JWT standard / convention. +/* eslint-disable camelcase */ + +export type FirebaseSignInProvider = + | 'custom' + | 'email' + | 'password' + | 'phone' + | 'anonymous' + | 'google.com' + | 'facebook.com' + | 'github.com' + | 'twitter.com' + | 'microsoft.com' + | 'apple.com'; + +interface FirebaseIdToken { + // Always set to https://securetoken.google.com/PROJECT_ID + iss: string; + + // Always set to PROJECT_ID + aud: string; + + // The user's unique id + sub: string; + + // The token issue time, in seconds since epoch + iat: number; + + // The token expiry time, normally 'iat' + 3600 + exp: number; + + // The user's unique id, must be equal to 'sub' + user_id: string; + + // The time the user authenticated, normally 'iat' + auth_time: number; + + // The sign in provider, only set when the provider is 'anonymous' + provider_id?: 'anonymous'; + + // The user's primary email + email?: string; + + // The user's email verification status + email_verified?: boolean; + + // The user's primary phone number + phone_number?: string; + + // The user's display name + name?: string; + + // The user's profile photo URL + picture?: string; + + // Information on all identities linked to this user + firebase: { + // The primary sign-in provider + sign_in_provider: FirebaseSignInProvider; + + // A map of providers to the user's list of unique identifiers from + // each provider + identities?: { [provider in FirebaseSignInProvider]?: string[] }; + }; + + // Custom claims set by the developer + [claim: string]: unknown; + + uid?: never; // Try to catch a common mistake of "uid" (should be "sub" instead). +} + +export type EmulatorMockTokenOptions = ({ user_id: string } | { sub: string }) & + Partial; + +export function createMockUserToken( + token: EmulatorMockTokenOptions, + projectId?: string +): string { + if (token.uid) { + throw new Error( + 'The "uid" field is no longer supported by mockUserToken. Please use "sub" instead for Firebase Auth User ID.' + ); + } + // Unsecured JWTs use "none" as the algorithm. + const header = { + alg: 'none', + type: 'JWT' + }; + + const project = projectId || 'demo-project'; + const iat = token.iat || 0; + const sub = token.sub || token.user_id; + if (!sub) { + throw new Error("mockUserToken must contain 'sub' or 'user_id' field!"); + } + + const payload: FirebaseIdToken = { + // Set all required fields to decent defaults + iss: `https://securetoken.google.com/${project}`, + aud: project, + iat, + exp: iat + 3600, + auth_time: iat, + sub, + user_id: sub, + firebase: { + sign_in_provider: 'custom', + identities: {} + }, + + // Override with user options + ...token + }; + + // Unsecured JWTs use the empty string as a signature. + const signature = ''; + return [ + base64.encodeString(JSON.stringify(header), /*webSafe=*/ false), + base64.encodeString(JSON.stringify(payload), /*webSafe=*/ false), + signature + ].join('.'); +} diff --git a/packages/util/test/emulator.test.ts b/packages/util/test/emulator.test.ts new file mode 100644 index 00000000000..2f1122dcc9f --- /dev/null +++ b/packages/util/test/emulator.test.ts @@ -0,0 +1,58 @@ +/** + * @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 { expect } from 'chai'; +import { base64 } from '../src/crypt'; +import { createMockUserToken, EmulatorMockTokenOptions } from '../src/emulator'; + +// Firebase Auth tokens contain snake_case claims following the JWT standard / convention. +/* eslint-disable camelcase */ + +describe('createMockUserToken()', () => { + it('creates a well-formed JWT', () => { + const projectId = 'my-project'; + const options = { user_id: 'alice' }; + + const token = createMockUserToken(options, projectId); + const claims = JSON.parse( + base64.decodeString(token.split('.')[1], /*webSafe=*/ false) + ); + // We add an 'iat' field. + expect(claims).to.deep.equal({ + iss: 'https://securetoken.google.com/' + projectId, + aud: projectId, + iat: 0, + exp: 3600, + auth_time: 0, + sub: 'alice', + user_id: 'alice', + firebase: { + sign_in_provider: 'custom', + identities: {} + } + }); + }); + + it('rejects "uid" field with error', () => { + const options = { uid: 'alice' }; + + expect(() => + createMockUserToken((options as unknown) as EmulatorMockTokenOptions) + ).to.throw( + 'The "uid" field is no longer supported by mockUserToken. Please use "sub" instead for Firebase Auth User ID.' + ); + }); +});