diff --git a/.changeset/wise-toys-care.md b/.changeset/wise-toys-care.md new file mode 100644 index 00000000000..e2bb7a68af3 --- /dev/null +++ b/.changeset/wise-toys-care.md @@ -0,0 +1,12 @@ +--- +'@firebase/database-types': minor +'@firebase/database': minor +'firebase': minor +'@firebase/firestore-types': minor +'@firebase/firestore': minor +'@firebase/storage-types': minor +'@firebase/storage': minor +'@firebase/util': minor +--- + +Implement mockUserToken for Storage and fix JWT format bugs. diff --git a/common/api-review/database.api.md b/common/api-review/database.api.md index 3fb78e72ac5..04e49d0230b 100644 --- a/common/api-review/database.api.md +++ b/common/api-review/database.api.md @@ -12,7 +12,7 @@ export function child(parent: DatabaseReference, path: string): DatabaseReferenc // @public export function connectDatabaseEmulator(db: Database, host: string, port: number, options?: { - mockUserToken?: EmulatorMockTokenOptions; + mockUserToken?: EmulatorMockTokenOptions | string; }): void; // @public diff --git a/common/api-review/firestore-lite.api.md b/common/api-review/firestore-lite.api.md index 624958d0239..454a4937c5d 100644 --- a/common/api-review/firestore-lite.api.md +++ b/common/api-review/firestore-lite.api.md @@ -51,7 +51,7 @@ export class CollectionReference extends Query { // @public export function connectFirestoreEmulator(firestore: Firestore, host: string, port: number, options?: { - mockUserToken?: EmulatorMockTokenOptions; + mockUserToken?: EmulatorMockTokenOptions | string; }): void; // @public diff --git a/common/api-review/firestore.api.md b/common/api-review/firestore.api.md index 301c6c2f8fc..f650f3d655e 100644 --- a/common/api-review/firestore.api.md +++ b/common/api-review/firestore.api.md @@ -57,7 +57,7 @@ export class CollectionReference extends Query { // @public export function connectFirestoreEmulator(firestore: Firestore, host: string, port: number, options?: { - mockUserToken?: EmulatorMockTokenOptions; + mockUserToken?: EmulatorMockTokenOptions | string; }): void; // @public diff --git a/common/api-review/storage.api.md b/common/api-review/storage.api.md index 5cae77227bc..92906ced407 100644 --- a/common/api-review/storage.api.md +++ b/common/api-review/storage.api.md @@ -6,6 +6,7 @@ import { AppCheckInternalComponentName } from '@firebase/app-check-interop-types'; import { CompleteFn } from '@firebase/util'; +import { EmulatorMockTokenOptions } from '@firebase/util'; import { FirebaseApp } from '@firebase/app'; import { FirebaseAuthInternalName } from '@firebase/auth-interop-types'; import { FirebaseError } from '@firebase/util'; @@ -16,7 +17,9 @@ import { Subscribe } from '@firebase/util'; import { Unsubscribe } from '@firebase/util'; // @public -export function connectStorageEmulator(storage: FirebaseStorage, host: string, port: number): void; +export function connectStorageEmulator(storage: FirebaseStorage, host: string, port: number, options?: { + mockUserToken?: EmulatorMockTokenOptions | string; +}): void; // @public export function deleteObject(ref: StorageReference): Promise; diff --git a/packages/database-types/index.d.ts b/packages/database-types/index.d.ts index 2b659016c95..00b0154a4bf 100644 --- a/packages/database-types/index.d.ts +++ b/packages/database-types/index.d.ts @@ -16,6 +16,7 @@ */ import { FirebaseApp } from '@firebase/app-types'; +import { EmulatorMockTokenOptions } from '@firebase/util'; export interface DataSnapshot { child(path: string): DataSnapshot; @@ -34,7 +35,13 @@ export interface DataSnapshot { export interface Database { app: FirebaseApp; - useEmulator(host: string, port: number): void; + useEmulator( + host: string, + port: number, + options?: { + mockUserToken?: EmulatorMockTokenOptions | string; + } + ): void; goOffline(): void; goOnline(): void; ref(path?: string | Reference): Reference; @@ -44,7 +51,13 @@ export interface Database { export class FirebaseDatabase implements Database { private constructor(); app: FirebaseApp; - useEmulator(host: string, port: number): void; + useEmulator( + host: string, + port: number, + options?: { + mockUserToken?: EmulatorMockTokenOptions | string; + } + ): void; goOffline(): void; goOnline(): void; ref(path?: string | Reference): Reference; diff --git a/packages/database-types/package.json b/packages/database-types/package.json index dc1c71809ce..d822ccba9df 100644 --- a/packages/database-types/package.json +++ b/packages/database-types/package.json @@ -12,7 +12,8 @@ "index.d.ts" ], "dependencies": { - "@firebase/app-types": "0.6.3" + "@firebase/app-types": "0.6.3", + "@firebase/util": "1.2.0" }, "repository": { "directory": "packages/database-types", diff --git a/packages/database/src/exp/Database.ts b/packages/database/src/exp/Database.ts index f4081e153c0..152877b46ce 100644 --- a/packages/database/src/exp/Database.ts +++ b/packages/database/src/exp/Database.ts @@ -308,7 +308,7 @@ export function connectDatabaseEmulator( host: string, port: number, options: { - mockUserToken?: EmulatorMockTokenOptions; + mockUserToken?: EmulatorMockTokenOptions | string; } = {} ): void { db = getModularInstance(db); @@ -329,10 +329,10 @@ export function connectDatabaseEmulator( } tokenProvider = new EmulatorTokenProvider(EmulatorTokenProvider.OWNER); } else if (options.mockUserToken) { - const token = createMockUserToken( - options.mockUserToken, - db.app.options.projectId - ); + const token = + typeof options.mockUserToken === 'string' + ? options.mockUserToken + : createMockUserToken(options.mockUserToken, db.app.options.projectId); tokenProvider = new EmulatorTokenProvider(token); } diff --git a/packages/firebase/index.d.ts b/packages/firebase/index.d.ts index 18057a17903..4a8a58dc713 100644 --- a/packages/firebase/index.d.ts +++ b/packages/firebase/index.d.ts @@ -5907,7 +5907,7 @@ declare namespace firebase.database { host: string, port: number, options?: { - mockUserToken?: EmulatorMockTokenOptions; + mockUserToken?: EmulatorMockTokenOptions | string; } ): void; /** @@ -7850,8 +7850,11 @@ declare namespace firebase.storage { * * @param host - The emulator host (ex: localhost) * @param port - The emulator port (ex: 5001) + * @param options.mockUserToken the mock auth token to use for unit testing Security Rules */ - useEmulator(host: string, port: number): void; + useEmulator(host: string, port: number, options?: { + mockUserToken?: EmulatorMockTokenOptions | string; + }): void; } /** @@ -8382,7 +8385,7 @@ declare namespace firebase.firestore { host: string, port: number, options?: { - mockUserToken?: EmulatorMockTokenOptions; + mockUserToken?: EmulatorMockTokenOptions | string; } ): void; diff --git a/packages/firestore-types/index.d.ts b/packages/firestore-types/index.d.ts index f7a76e32064..7085d4eaef7 100644 --- a/packages/firestore-types/index.d.ts +++ b/packages/firestore-types/index.d.ts @@ -65,7 +65,7 @@ export class FirebaseFirestore { host: string, port: number, options?: { - mockUserToken?: EmulatorMockTokenOptions; + mockUserToken?: EmulatorMockTokenOptions | string; } ): void; diff --git a/packages/firestore/src/api/database.ts b/packages/firestore/src/api/database.ts index c411534e117..8ead323e3d0 100644 --- a/packages/firestore/src/api/database.ts +++ b/packages/firestore/src/api/database.ts @@ -244,7 +244,7 @@ export class Firestore host: string, port: number, options: { - mockUserToken?: EmulatorMockTokenOptions; + mockUserToken?: EmulatorMockTokenOptions | string; } = {} ): void { connectFirestoreEmulator(this._delegate, host, port, options); diff --git a/packages/firestore/src/auth/user.ts b/packages/firestore/src/auth/user.ts index c4c2187d646..ddb3e2ac748 100644 --- a/packages/firestore/src/auth/user.ts +++ b/packages/firestore/src/auth/user.ts @@ -27,6 +27,7 @@ export class User { // non-FirebaseAuth providers. static readonly GOOGLE_CREDENTIALS = new User('google-credentials-uid'); static readonly FIRST_PARTY = new User('first-party-uid'); + static readonly MOCK_USER = new User('mock-user'); constructor(readonly uid: string | null) {} diff --git a/packages/firestore/src/lite/database.ts b/packages/firestore/src/lite/database.ts index 3c55aec83f0..94c7f9f609c 100644 --- a/packages/firestore/src/lite/database.ts +++ b/packages/firestore/src/lite/database.ts @@ -238,7 +238,7 @@ export function connectFirestoreEmulator( host: string, port: number, options: { - mockUserToken?: EmulatorMockTokenOptions; + mockUserToken?: EmulatorMockTokenOptions | string; } = {} ): void { firestore = cast(firestore, Firestore); @@ -258,19 +258,30 @@ export function connectFirestoreEmulator( }); if (options.mockUserToken) { - // Let createMockUserToken validate first (catches common mistakes like - // invalid field "uid" and missing field "sub" / "user_id".) - const token = createMockUserToken(options.mockUserToken); - const uid = options.mockUserToken.sub || options.mockUserToken.user_id; - if (!uid) { - throw new FirestoreError( - Code.INVALID_ARGUMENT, - "mockUserToken must contain 'sub' or 'user_id' field!" + let token: string; + let user: User; + if (typeof options.mockUserToken === 'string') { + token = options.mockUserToken; + user = User.MOCK_USER; + } else { + // Let createMockUserToken validate first (catches common mistakes like + // invalid field "uid" and missing field "sub" / "user_id".) + token = createMockUserToken( + options.mockUserToken, + firestore._app?.options.projectId ); + const uid = options.mockUserToken.sub || options.mockUserToken.user_id; + if (!uid) { + throw new FirestoreError( + Code.INVALID_ARGUMENT, + "mockUserToken must contain 'sub' or 'user_id' field!" + ); + } + user = new User(uid); } firestore._credentials = new EmulatorCredentialsProvider( - new OAuthToken(token, new User(uid)) + new OAuthToken(token, user) ); } } diff --git a/packages/firestore/test/integration/api/validation.test.ts b/packages/firestore/test/integration/api/validation.test.ts index 345916eb09e..e47578ee1b4 100644 --- a/packages/firestore/test/integration/api/validation.test.ts +++ b/packages/firestore/test/integration/api/validation.test.ts @@ -158,11 +158,27 @@ apiDescribe('Validation:', (persistence: boolean) => { } ); - validationIt(persistence, 'useEmulator can set mockUserToken', () => { - const db = newTestFirestore('test-project'); - // Verify that this doesn't throw. - db.useEmulator('localhost', 9000, { mockUserToken: { sub: 'foo' } }); - }); + validationIt( + persistence, + 'useEmulator can set mockUserToken object', + () => { + const db = newTestFirestore('test-project'); + // Verify that this doesn't throw. + db.useEmulator('localhost', 9000, { mockUserToken: { sub: 'foo' } }); + } + ); + + validationIt( + persistence, + 'useEmulator can set mockUserToken string', + () => { + const db = newTestFirestore('test-project'); + // Verify that this doesn't throw. + db.useEmulator('localhost', 9000, { + mockUserToken: 'my-mock-user-token' + }); + } + ); validationIt( persistence, diff --git a/packages/firestore/test/unit/api/database.test.ts b/packages/firestore/test/unit/api/database.test.ts index 8ba1d406a6d..8ffec48399a 100644 --- a/packages/firestore/test/unit/api/database.test.ts +++ b/packages/firestore/test/unit/api/database.test.ts @@ -18,6 +18,7 @@ import { expect } from 'chai'; import { EmulatorCredentialsProvider } from '../../../src/api/credentials'; +import { User } from '../../../src/auth/user'; import { collectionReference, documentReference, @@ -252,7 +253,7 @@ describe('Settings', () => { expect(db._delegate._getSettings().ssl).to.be.false; }); - it('sets credentials based on mockUserToken', async () => { + it('sets credentials based on mockUserToken object', async () => { // Use a new instance of Firestore in order to configure settings. const db = newTestFirestore(); const mockUserToken = { sub: 'foobar' }; @@ -264,4 +265,18 @@ describe('Settings', () => { expect(token!.type).to.eql('OAuth'); expect(token!.user.uid).to.eql(mockUserToken.sub); }); + + it('sets credentials based on mockUserToken string', async () => { + // Use a new instance of Firestore in order to configure settings. + const db = newTestFirestore(); + db.useEmulator('localhost', 9000, { + mockUserToken: 'my-custom-mock-user-token' + }); + + const credentials = db._delegate._credentials; + expect(credentials).to.be.instanceOf(EmulatorCredentialsProvider); + const token = await credentials.getToken(); + expect(token!.type).to.eql('OAuth'); + expect(token!.user).to.eql(User.MOCK_USER); + }); }); diff --git a/packages/rules-unit-testing/src/api/index.ts b/packages/rules-unit-testing/src/api/index.ts index 9cf12e24af9..51d16209187 100644 --- a/packages/rules-unit-testing/src/api/index.ts +++ b/packages/rules-unit-testing/src/api/index.ts @@ -160,7 +160,7 @@ export type FirebaseEmulatorOptions = { function trimmedBase64Encode(val: string): string { // Use base64url encoding and remove padding in the end (dot characters). - return base64Encode(val).replace(/\./g, ''); + return base64Encode(val).replace(/\./g, ""); } function createUnsecuredJwt(token: TokenOptions, projectId?: string): string { @@ -498,7 +498,7 @@ function initializeApp( ComponentType.PRIVATE ); - (app as unknown as _FirebaseApp)._addOrOverwriteComponent( + ((app as unknown) as _FirebaseApp)._addOrOverwriteComponent( mockAuthComponent ); } @@ -703,7 +703,7 @@ export function assertFails(pr: Promise): any { errCode === 'permission-denied' || errCode === 'permission_denied' || errMessage.indexOf('permission_denied') >= 0 || - errMessage.indexOf('permission denied') >= 0 || + errMessage.indexOf('permission denied') >= 0 || // Storage permission errors contain message: (storage/unauthorized) errMessage.indexOf('unauthorized') >= 0; diff --git a/packages/storage-types/index.d.ts b/packages/storage-types/index.d.ts index 735d8c32278..7707043777b 100644 --- a/packages/storage-types/index.d.ts +++ b/packages/storage-types/index.d.ts @@ -16,7 +16,13 @@ */ import { FirebaseApp } from '@firebase/app-types'; -import { CompleteFn, FirebaseError, NextFn, Unsubscribe } from '@firebase/util'; +import { + CompleteFn, + EmulatorMockTokenOptions, + FirebaseError, + NextFn, + Unsubscribe +} from '@firebase/util'; export interface FullMetadata extends UploadMetadata { bucket: string; @@ -135,7 +141,14 @@ export class FirebaseStorage { refFromURL(url: string): Reference; setMaxOperationRetryTime(time: number): void; setMaxUploadRetryTime(time: number): void; - useEmulator(host: string, port: number): void; + + useEmulator( + host: string, + port: number, + options?: { + mockUserToken?: EmulatorMockTokenOptions | string; + } + ): void; } declare module '@firebase/component' { diff --git a/packages/storage/compat/service.ts b/packages/storage/compat/service.ts index 8eeade12257..17a4cb952d4 100644 --- a/packages/storage/compat/service.ts +++ b/packages/storage/compat/service.ts @@ -27,7 +27,7 @@ import { import { ReferenceCompat } from './reference'; import { isUrl, FirebaseStorageImpl } from '../src/service'; import { invalidArgument } from '../src/implementation/error'; -import { Compat } from '@firebase/util'; +import { Compat, EmulatorMockTokenOptions } from '@firebase/util'; /** * A service that provides firebaseStorage.Reference instances. @@ -87,7 +87,13 @@ export class StorageServiceCompat this._delegate.maxOperationRetryTime = time; } - useEmulator(host: string, port: number): void { - connectStorageEmulator(this._delegate, host, port); + useEmulator( + host: string, + port: number, + options: { + mockUserToken?: EmulatorMockTokenOptions | string; + } = {} + ): void { + connectStorageEmulator(this._delegate, host, port, options); } } diff --git a/packages/storage/exp/api.ts b/packages/storage/exp/api.ts index 762c260c72b..80f067fa2f2 100644 --- a/packages/storage/exp/api.ts +++ b/packages/storage/exp/api.ts @@ -50,7 +50,7 @@ import { _getChild as _getChildInternal } from '../src/reference'; import { STORAGE_TYPE } from './constants'; -import { getModularInstance } from '@firebase/util'; +import { EmulatorMockTokenOptions, getModularInstance } from '@firebase/util'; /** * Public types. @@ -307,12 +307,16 @@ export function getStorage( * @param storage - The `StorageService` instance * @param host - The emulator host (ex: localhost) * @param port - The emulator port (ex: 5001) + * @param options.mockUserToken - the mock auth token to use for unit testing Security Rules. * @public */ export function connectStorageEmulator( storage: FirebaseStorage, host: string, - port: number + port: number, + options: { + mockUserToken?: EmulatorMockTokenOptions | string; + } = {} ): void { - connectEmulatorInternal(storage as FirebaseStorageImpl, host, port); + connectEmulatorInternal(storage as FirebaseStorageImpl, host, port, options); } diff --git a/packages/storage/src/service.ts b/packages/storage/src/service.ts index 0a0078c41a4..e2902181d9a 100644 --- a/packages/storage/src/service.ts +++ b/packages/storage/src/service.ts @@ -39,6 +39,7 @@ import { } from './implementation/error'; import { validateNumber } from './implementation/type'; import { FirebaseStorage } from '../exp/public-types'; +import { createMockUserToken, EmulatorMockTokenOptions } from '@firebase/util'; export function isUrl(path?: string): boolean { return /^[A-Za-z]+:\/\//.test(path as string); @@ -133,9 +134,19 @@ function extractBucket( export function connectStorageEmulator( storage: FirebaseStorageImpl, host: string, - port: number + port: number, + options: { + mockUserToken?: EmulatorMockTokenOptions | string; + } = {} ): void { storage.host = `http://${host}:${port}`; + const { mockUserToken } = options; + if (mockUserToken) { + storage._overrideAuthToken = + typeof mockUserToken === 'string' + ? mockUserToken + : createMockUserToken(mockUserToken, storage.app.options.projectId); + } } /** @@ -157,6 +168,7 @@ export class FirebaseStorageImpl implements FirebaseStorage { private _deleted: boolean = false; private _maxOperationRetryTime: number; private _maxUploadRetryTime: number; + _overrideAuthToken?: string; constructor( /** @@ -239,6 +251,9 @@ export class FirebaseStorageImpl implements FirebaseStorage { } async _getAuthToken(): Promise { + if (this._overrideAuthToken) { + return this._overrideAuthToken; + } const auth = this._authProvider.getImmediate({ optional: true }); if (auth) { const tokenData = await auth.getToken(); diff --git a/packages/storage/test/unit/service.exp.test.ts b/packages/storage/test/unit/service.exp.test.ts index a5f4c143f3c..5129d7d27d8 100644 --- a/packages/storage/test/unit/service.exp.test.ts +++ b/packages/storage/test/unit/service.exp.test.ts @@ -235,7 +235,7 @@ GOOG4-RSA-SHA256` ); }); }); - describe('connectStorageEmulator(service, host, port)', () => { + describe('connectStorageEmulator(service, host, port, options)', () => { it('sets emulator host correctly', done => { function newSend( connection: TestingConnection, @@ -260,6 +260,65 @@ GOOG4-RSA-SHA256` expect(service.host).to.equal('http://test.host.org:1234'); void getDownloadURL(ref(service, 'test.png')); }); + it('sets mock user token string if specified', done => { + const mockUserToken = 'my-mock-user-token'; + function newSend( + connection: TestingConnection, + url: string, + method: string, + body?: ArrayBufferView | Blob | string | null, + headers?: Headers + ): void { + // Expect emulator host to be in url of storage operations requests, + // in this case getDownloadURL. + expect(url).to.match(/^http:\/\/test\.host\.org:1234.+/); + expect(headers?.['Authorization']).to.eql(`Firebase ${mockUserToken}`); + connection.abort(); + done(); + } + const service = new FirebaseStorageImpl( + testShared.fakeApp, + testShared.fakeAuthProvider, + testShared.fakeAppCheckTokenProvider, + testShared.makePool(newSend) + ); + connectStorageEmulator(service, 'test.host.org', 1234, { mockUserToken }); + expect(service.host).to.equal('http://test.host.org:1234'); + expect(service._overrideAuthToken).to.equal(mockUserToken); + void getDownloadURL(ref(service, 'test.png')); + }); + it('creates mock user token from object if specified', done => { + let token: string | undefined = undefined; + function newSend( + connection: TestingConnection, + url: string, + method: string, + body?: ArrayBufferView | Blob | string | null, + headers?: Headers + ): void { + // Expect emulator host to be in url of storage operations requests, + // in this case getDownloadURL. + expect(url).to.match(/^http:\/\/test\.host\.org:1234.+/); + expect(headers?.['Authorization']).to.eql(`Firebase ${token}`); + connection.abort(); + done(); + } + + const service = new FirebaseStorageImpl( + testShared.fakeApp, + testShared.fakeAuthProvider, + testShared.fakeAppCheckTokenProvider, + testShared.makePool(newSend) + ); + connectStorageEmulator(service, 'test.host.org', 1234, { + mockUserToken: { sub: 'alice' } + }); + expect(service.host).to.equal('http://test.host.org:1234'); + token = service._overrideAuthToken; + // Token should be an unsigned JWT with header { "alg": "none", "type": "JWT" } (base64url): + expect(token).to.match(/^eyJhbGciOiJub25lIiwidHlwZSI6IkpXVCJ9\./); + void getDownloadURL(ref(service, 'test.png')); + }); }); describe('ref(service, path)', () => { const service = new FirebaseStorageImpl( diff --git a/packages/util/src/crypt.ts b/packages/util/src/crypt.ts index 56dba121c0c..df1e058756f 100644 --- a/packages/util/src/crypt.ts +++ b/packages/util/src/crypt.ts @@ -341,6 +341,15 @@ export const base64Encode = function (str: string): string { return base64.encodeByteArray(utf8Bytes, true); }; +/** + * URL-safe base64 encoding (without "." padding in the end). + * e.g. Used in JSON Web Token (JWT) parts. + */ +export const base64urlEncodeWithoutPadding = function (str: string): string { + // Use base64url encoding and remove padding in the end (dot characters). + return base64Encode(str).replace(/\./g, ''); +}; + /** * URL-safe base64 decoding * diff --git a/packages/util/src/emulator.ts b/packages/util/src/emulator.ts index 1119a298868..2850b5be378 100644 --- a/packages/util/src/emulator.ts +++ b/packages/util/src/emulator.ts @@ -15,7 +15,7 @@ * limitations under the License. */ -import { base64 } from './crypt'; +import { base64urlEncodeWithoutPadding } from './crypt'; // Firebase Auth tokens contain snake_case claims following the JWT standard / convention. /* eslint-disable camelcase */ @@ -135,8 +135,8 @@ export function createMockUserToken( // 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), + base64urlEncodeWithoutPadding(JSON.stringify(header)), + base64urlEncodeWithoutPadding(JSON.stringify(payload)), signature ].join('.'); } diff --git a/packages/util/test/emulator.test.ts b/packages/util/test/emulator.test.ts index 2f1122dcc9f..34ebefd9203 100644 --- a/packages/util/test/emulator.test.ts +++ b/packages/util/test/emulator.test.ts @@ -28,7 +28,7 @@ describe('createMockUserToken()', () => { const token = createMockUserToken(options, projectId); const claims = JSON.parse( - base64.decodeString(token.split('.')[1], /*webSafe=*/ false) + base64.decodeString(token.split('.')[1], /*webSafe=*/ true) ); // We add an 'iat' field. expect(claims).to.deep.equal({ @@ -50,7 +50,7 @@ describe('createMockUserToken()', () => { const options = { uid: 'alice' }; expect(() => - createMockUserToken((options as unknown) as EmulatorMockTokenOptions) + 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.' );