From 80a7f7cd183bce754929d516deecdd23b05027b0 Mon Sep 17 00:00:00 2001 From: prameshj Date: Mon, 31 Jul 2023 11:14:20 -0700 Subject: [PATCH 01/15] Pin the webdriver version in Firefox test to avoid install failures. (#7504) Example error - https://github.com/firebase/firebase-js-sdk/actions/runs/5693107410/job/15431784566?pr=7501 "No such object: chromedriver/LATEST_RELEASE_115.0.5790" --- .github/workflows/test-changed-auth.yml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.github/workflows/test-changed-auth.yml b/.github/workflows/test-changed-auth.yml index 1faaa2c449a..4b6ba46cf44 100644 --- a/.github/workflows/test-changed-auth.yml +++ b/.github/workflows/test-changed-auth.yml @@ -45,13 +45,20 @@ jobs: # Whatever version of Firefox comes with 22.04 is causing Firefox # startup to hang when launched by karma. Need to look further into # why. + runs-on: ubuntu-20.04 + # Chrome webdriver version is pinned to avoid install failures like "No such object: chromedriver/LATEST_RELEASE_115.0.5790" + # These are installed even in the Firefox test due to `yarn` command which pulls the devDependency listed in package.json. steps: - name: install Firefox stable run: | sudo apt-get update sudo apt-get install firefox + sudo apt-get install wget + sudo wget http://dl.google.com/linux/chrome/deb/pool/main/g/google-chrome-stable/google-chrome-stable_110.0.5481.177-1_amd64.deb + sudo apt-get install -f ./google-chrome-stable_110.0.5481.177-1_amd64.deb --allow-downgrades + - name: Checkout Repo uses: actions/checkout@master with: From e037eeed61c92375884c2aab3246364f0545b3df Mon Sep 17 00:00:00 2001 From: Tiffany Zheng <125598214+qtz1@users.noreply.github.com> Date: Tue, 1 Aug 2023 12:18:42 -0400 Subject: [PATCH 02/15] Export internal interface PrivateSettings for console (#7492) Expose PrivateSettings interface as an internal type for the Firestore console. It was exported in the previous version of the non-modular firestore SDK we used in console. --- packages/firestore/src/api.ts | 1 + packages/firestore/src/lite-api/settings.ts | 5 ++++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/firestore/src/api.ts b/packages/firestore/src/api.ts index 0a092cba1b6..aabc632d2f6 100644 --- a/packages/firestore/src/api.ts +++ b/packages/firestore/src/api.ts @@ -82,6 +82,7 @@ export { } from './api/bundle'; export { FirestoreSettings, PersistenceSettings } from './api/settings'; +export type { PrivateSettings } from './lite-api/settings'; export { ExperimentalLongPollingOptions } from './api/long_polling_options'; export { diff --git a/packages/firestore/src/lite-api/settings.ts b/packages/firestore/src/lite-api/settings.ts index ebfbb239bcc..20551111a4f 100644 --- a/packages/firestore/src/lite-api/settings.ts +++ b/packages/firestore/src/lite-api/settings.ts @@ -68,7 +68,10 @@ export interface FirestoreSettings { ignoreUndefinedProperties?: boolean; } -/** Undocumented, private additional settings not exposed in our public API. */ +/** + * @internal + * Undocumented, private additional settings not exposed in our public API. + */ export interface PrivateSettings extends FirestoreSettings { // Can be a google-auth-library or gapi client. credentials?: CredentialsSettings; From e201e5390c32c8ab1d61e5ff34ef1bb7615be62f Mon Sep 17 00:00:00 2001 From: Denver Coneybeare Date: Thu, 3 Aug 2023 10:44:47 -0400 Subject: [PATCH 03/15] Firestore: equality_matcher.ts: fix missing custom assertion failure message in deep equals (#7519) * equality_matcher.ts: fix absence of custom message in assertion failure messages * equality_matcher.ts: remove usage of the now-deleted `msg` argument * equality_matcher.ts: fix type of `originalFunction` --- packages/firestore/test/util/equality_matcher.ts | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/packages/firestore/test/util/equality_matcher.ts b/packages/firestore/test/util/equality_matcher.ts index 9dfa1469503..6fe1b6d831c 100644 --- a/packages/firestore/test/util/equality_matcher.ts +++ b/packages/firestore/test/util/equality_matcher.ts @@ -123,7 +123,7 @@ function customDeepEqual( } /** The original equality function passed in by chai(). */ -let originalFunction: ((r: unknown, l: unknown) => boolean) | null = null; +let originalFunction: ((expected: unknown) => void) | null = null; export function addEqualityMatcher( // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -136,15 +136,10 @@ export function addEqualityMatcher( const Assertion = chai.Assertion; // eslint-disable-next-line @typescript-eslint/explicit-function-return-type - const assertEql = (_super: (r: unknown, l: unknown) => boolean) => { + const assertEql = (_super: (expected: unknown) => void) => { originalFunction = originalFunction || _super; - return function ( - this: Chai.Assertion, - expected?: unknown, - msg?: unknown - ): void { + return function (this: Chai.Assertion, expected?: unknown): void { if (isActive) { - utils.flag(this, 'message', msg); const actual = utils.flag(this, 'object'); // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -161,7 +156,7 @@ export function addEqualityMatcher( /*showDiff=*/ true ); } else if (originalFunction) { - originalFunction.call(this, expected, msg); + originalFunction.call(this, expected); } }; }; From b395277f3de5d017df7b2edfba329682a0928453 Mon Sep 17 00:00:00 2001 From: Mark Duckworth <1124037+MarkDuckworth@users.noreply.github.com> Date: Fri, 4 Aug 2023 08:53:28 -0600 Subject: [PATCH 04/15] Fix: Aggregate query order-by normalization (#7507) Fix aggregate query order-by normalization to support future aggregate operations. --- .changeset/quiet-gorillas-smoke.md | 5 + packages/firestore/src/core/query.ts | 164 +++++++++++------- packages/firestore/src/lite-api/query.ts | 4 +- packages/firestore/src/remote/datastore.ts | 4 +- .../firestore/test/unit/core/query.test.ts | 56 +++++- 5 files changed, 163 insertions(+), 70 deletions(-) create mode 100644 .changeset/quiet-gorillas-smoke.md diff --git a/.changeset/quiet-gorillas-smoke.md b/.changeset/quiet-gorillas-smoke.md new file mode 100644 index 00000000000..0d37f100961 --- /dev/null +++ b/.changeset/quiet-gorillas-smoke.md @@ -0,0 +1,5 @@ +--- +"@firebase/firestore": patch +--- + +Refactored aggregate query order-by normalization to support future aggregate operations. diff --git a/packages/firestore/src/core/query.ts b/packages/firestore/src/core/query.ts index 9045a470e2c..098f57f1927 100644 --- a/packages/firestore/src/core/query.ts +++ b/packages/firestore/src/core/query.ts @@ -43,7 +43,7 @@ export const enum LimitType { /** * The Query interface defines all external properties of a query. * - * QueryImpl implements this interface to provide memoization for `queryOrderBy` + * QueryImpl implements this interface to provide memoization for `queryNormalizedOrderBy` * and `queryToTarget`. */ export interface Query { @@ -65,11 +65,18 @@ export interface Query { * Visible for testing. */ export class QueryImpl implements Query { - memoizedOrderBy: OrderBy[] | null = null; + memoizedNormalizedOrderBy: OrderBy[] | null = null; - // The corresponding `Target` of this `Query` instance. + // The corresponding `Target` of this `Query` instance, for use with + // non-aggregate queries. memoizedTarget: Target | null = null; + // The corresponding `Target` of this `Query` instance, for use with + // aggregate queries. Unlike targets for non-aggregate queries, + // aggregate query targets do not contain normalized order-bys, they only + // contain explicit order-bys. + memoizedAggregateTarget: Target | null = null; + /** * Initializes a Query with a path and optional additional query constraints. * Path must currently be empty if this is a collection group query. @@ -86,13 +93,13 @@ export class QueryImpl implements Query { ) { if (this.startAt) { debugAssert( - this.startAt.position.length <= queryOrderBy(this).length, + this.startAt.position.length <= queryNormalizedOrderBy(this).length, 'Bound is longer than orderBy' ); } if (this.endAt) { debugAssert( - this.endAt.position.length <= queryOrderBy(this).length, + this.endAt.position.length <= queryNormalizedOrderBy(this).length, 'Bound is longer than orderBy' ); } @@ -211,14 +218,16 @@ export function isCollectionGroupQuery(query: Query): boolean { } /** - * Returns the implicit order by constraint that is used to execute the Query, - * which can be different from the order by constraints the user provided (e.g. - * the SDK and backend always orders by `__name__`). + * Returns the normalized order-by constraint that is used to execute the Query, + * which can be different from the order-by constraints the user provided (e.g. + * the SDK and backend always orders by `__name__`). The normalized order-by + * includes implicit order-bys in addition to the explicit user provided + * order-bys. */ -export function queryOrderBy(query: Query): OrderBy[] { +export function queryNormalizedOrderBy(query: Query): OrderBy[] { const queryImpl = debugCast(query, QueryImpl); - if (queryImpl.memoizedOrderBy === null) { - queryImpl.memoizedOrderBy = []; + if (queryImpl.memoizedNormalizedOrderBy === null) { + queryImpl.memoizedNormalizedOrderBy = []; const inequalityField = getInequalityFilterField(queryImpl); const firstOrderByField = getFirstOrderByField(queryImpl); @@ -227,9 +236,9 @@ export function queryOrderBy(query: Query): OrderBy[] { // inequality filter field for it to be a valid query. // Note that the default inequality field and key ordering is ascending. if (!inequalityField.isKeyField()) { - queryImpl.memoizedOrderBy.push(new OrderBy(inequalityField)); + queryImpl.memoizedNormalizedOrderBy.push(new OrderBy(inequalityField)); } - queryImpl.memoizedOrderBy.push( + queryImpl.memoizedNormalizedOrderBy.push( new OrderBy(FieldPath.keyField(), Direction.ASCENDING) ); } else { @@ -241,76 +250,103 @@ export function queryOrderBy(query: Query): OrderBy[] { ); let foundKeyOrdering = false; for (const orderBy of queryImpl.explicitOrderBy) { - queryImpl.memoizedOrderBy.push(orderBy); + queryImpl.memoizedNormalizedOrderBy.push(orderBy); if (orderBy.field.isKeyField()) { foundKeyOrdering = true; } } if (!foundKeyOrdering) { // The order of the implicit key ordering always matches the last - // explicit order by + // explicit order-by const lastDirection = queryImpl.explicitOrderBy.length > 0 ? queryImpl.explicitOrderBy[queryImpl.explicitOrderBy.length - 1] .dir : Direction.ASCENDING; - queryImpl.memoizedOrderBy.push( + queryImpl.memoizedNormalizedOrderBy.push( new OrderBy(FieldPath.keyField(), lastDirection) ); } } } - return queryImpl.memoizedOrderBy; + return queryImpl.memoizedNormalizedOrderBy; } /** - * Converts this `Query` instance to it's corresponding `Target` representation. + * Converts this `Query` instance to its corresponding `Target` representation. */ export function queryToTarget(query: Query): Target { const queryImpl = debugCast(query, QueryImpl); if (!queryImpl.memoizedTarget) { - if (queryImpl.limitType === LimitType.First) { - queryImpl.memoizedTarget = newTarget( - queryImpl.path, - queryImpl.collectionGroup, - queryOrderBy(queryImpl), - queryImpl.filters, - queryImpl.limit, - queryImpl.startAt, - queryImpl.endAt - ); - } else { - // Flip the orderBy directions since we want the last results - const orderBys = [] as OrderBy[]; - for (const orderBy of queryOrderBy(queryImpl)) { - const dir = - orderBy.dir === Direction.DESCENDING - ? Direction.ASCENDING - : Direction.DESCENDING; - orderBys.push(new OrderBy(orderBy.field, dir)); - } + queryImpl.memoizedTarget = _queryToTarget( + queryImpl, + queryNormalizedOrderBy(query) + ); + } - // We need to swap the cursors to match the now-flipped query ordering. - const startAt = queryImpl.endAt - ? new Bound(queryImpl.endAt.position, queryImpl.endAt.inclusive) - : null; - const endAt = queryImpl.startAt - ? new Bound(queryImpl.startAt.position, queryImpl.startAt.inclusive) - : null; - - // Now return as a LimitType.First query. - queryImpl.memoizedTarget = newTarget( - queryImpl.path, - queryImpl.collectionGroup, - orderBys, - queryImpl.filters, - queryImpl.limit, - startAt, - endAt - ); - } + return queryImpl.memoizedTarget; +} + +/** + * Converts this `Query` instance to its corresponding `Target` representation, + * for use within an aggregate query. Unlike targets for non-aggregate queries, + * aggregate query targets do not contain normalized order-bys, they only + * contain explicit order-bys. + */ +export function queryToAggregateTarget(query: Query): Target { + const queryImpl = debugCast(query, QueryImpl); + + if (!queryImpl.memoizedAggregateTarget) { + // Do not include implicit order-bys for aggregate queries. + queryImpl.memoizedAggregateTarget = _queryToTarget( + queryImpl, + query.explicitOrderBy + ); + } + + return queryImpl.memoizedAggregateTarget; +} + +function _queryToTarget(queryImpl: QueryImpl, orderBys: OrderBy[]): Target { + if (queryImpl.limitType === LimitType.First) { + return newTarget( + queryImpl.path, + queryImpl.collectionGroup, + orderBys, + queryImpl.filters, + queryImpl.limit, + queryImpl.startAt, + queryImpl.endAt + ); + } else { + // Flip the orderBy directions since we want the last results + orderBys = orderBys.map(orderBy => { + const dir = + orderBy.dir === Direction.DESCENDING + ? Direction.ASCENDING + : Direction.DESCENDING; + return new OrderBy(orderBy.field, dir); + }); + + // We need to swap the cursors to match the now-flipped query ordering. + const startAt = queryImpl.endAt + ? new Bound(queryImpl.endAt.position, queryImpl.endAt.inclusive) + : null; + const endAt = queryImpl.startAt + ? new Bound(queryImpl.startAt.position, queryImpl.startAt.inclusive) + : null; + + // Now return as a LimitType.First query. + return newTarget( + queryImpl.path, + queryImpl.collectionGroup, + orderBys, + queryImpl.filters, + queryImpl.limit, + startAt, + endAt + ); } - return queryImpl.memoizedTarget!; } export function queryWithAddedFilter(query: Query, filter: Filter): Query { @@ -461,14 +497,14 @@ function queryMatchesPathAndCollectionGroup( * in the results. */ function queryMatchesOrderBy(query: Query, doc: Document): boolean { - // We must use `queryOrderBy()` to get the list of all orderBys (both implicit and explicit). + // We must use `queryNormalizedOrderBy()` to get the list of all orderBys (both implicit and explicit). // Note that for OR queries, orderBy applies to all disjunction terms and implicit orderBys must // be taken into account. For example, the query "a > 1 || b==1" has an implicit "orderBy a" due // to the inequality, and is evaluated as "a > 1 orderBy a || b==1 orderBy a". // A document with content of {b:1} matches the filters, but does not match the orderBy because // it's missing the field 'a'. - for (const orderBy of queryOrderBy(query)) { - // order by key always matches + for (const orderBy of queryNormalizedOrderBy(query)) { + // order-by key always matches if (!orderBy.field.isKeyField() && doc.data.field(orderBy.field) === null) { return false; } @@ -489,13 +525,13 @@ function queryMatchesFilters(query: Query, doc: Document): boolean { function queryMatchesBounds(query: Query, doc: Document): boolean { if ( query.startAt && - !boundSortsBeforeDocument(query.startAt, queryOrderBy(query), doc) + !boundSortsBeforeDocument(query.startAt, queryNormalizedOrderBy(query), doc) ) { return false; } if ( query.endAt && - !boundSortsAfterDocument(query.endAt, queryOrderBy(query), doc) + !boundSortsAfterDocument(query.endAt, queryNormalizedOrderBy(query), doc) ) { return false; } @@ -526,7 +562,7 @@ export function newQueryComparator( ): (d1: Document, d2: Document) => number { return (d1: Document, d2: Document): number => { let comparedOnKeyField = false; - for (const orderBy of queryOrderBy(query)) { + for (const orderBy of queryNormalizedOrderBy(query)) { const comp = compareDocs(orderBy, d1, d2); if (comp !== 0) { return comp; diff --git a/packages/firestore/src/lite-api/query.ts b/packages/firestore/src/lite-api/query.ts index 617a60a4462..2283c92b3e1 100644 --- a/packages/firestore/src/lite-api/query.ts +++ b/packages/firestore/src/lite-api/query.ts @@ -33,7 +33,7 @@ import { isCollectionGroupQuery, LimitType, Query as InternalQuery, - queryOrderBy, + queryNormalizedOrderBy, queryWithAddedFilter, queryWithAddedOrderBy, queryWithEndAt, @@ -907,7 +907,7 @@ export function newQueryBoundFromDocument( // the provided document. Without the key (by using the explicit sort // orders), multiple documents could match the position, yielding duplicate // results. - for (const orderBy of queryOrderBy(query)) { + for (const orderBy of queryNormalizedOrderBy(query)) { if (orderBy.field.isKeyField()) { components.push(refValue(databaseId, doc.key)); } else { diff --git a/packages/firestore/src/remote/datastore.ts b/packages/firestore/src/remote/datastore.ts index de26e2435b6..9c85a48bf63 100644 --- a/packages/firestore/src/remote/datastore.ts +++ b/packages/firestore/src/remote/datastore.ts @@ -18,7 +18,7 @@ import { CredentialsProvider } from '../api/credentials'; import { User } from '../auth/user'; import { Aggregate } from '../core/aggregate'; -import { Query, queryToTarget } from '../core/query'; +import { queryToAggregateTarget, Query, queryToTarget } from '../core/query'; import { Document } from '../model/document'; import { DocumentKey } from '../model/document_key'; import { Mutation } from '../model/mutation'; @@ -248,7 +248,7 @@ export async function invokeRunAggregationQueryRpc( const datastoreImpl = debugCast(datastore, DatastoreImpl); const { request, aliasMap } = toRunAggregationQueryRequest( datastoreImpl.serializer, - queryToTarget(query), + queryToAggregateTarget(query), aggregates ); diff --git a/packages/firestore/test/unit/core/query.test.ts b/packages/firestore/test/unit/core/query.test.ts index c88532474be..cfd5d99adeb 100644 --- a/packages/firestore/test/unit/core/query.test.ts +++ b/packages/firestore/test/unit/core/query.test.ts @@ -27,12 +27,13 @@ import { newQueryForPath, Query, queryMatches, - queryOrderBy, + queryNormalizedOrderBy, queryWithAddedFilter, queryWithEndAt, queryWithLimit, queryWithStartAt, stringifyQuery, + queryToAggregateTarget, queryToTarget, QueryImpl, queryEquals, @@ -852,6 +853,57 @@ describe('Query', () => { assertQueryMatches(query5, [doc3], [doc1, doc2, doc4, doc5]); }); + it('generates appropriate order-bys for aggregate and non-aggregate targets', () => { + const col = newQueryForPath(ResourcePath.fromString('collection')); + + // Build two identical queries + const query1 = queryWithAddedFilter(col, filter('foo', '>', 1)); + const query2 = queryWithAddedFilter(col, filter('foo', '>', 1)); + + // Compute an aggregate and non-aggregate target from the queries + const aggregateTarget = queryToAggregateTarget(query1); + const target = queryToTarget(query2); + + expect(aggregateTarget.orderBy.length).to.equal(0); + expect(target.orderBy.length).to.equal(2); + expect(target.orderBy[0].dir).to.equal('asc'); + expect(target.orderBy[0].field.canonicalString()).to.equal('foo'); + expect(target.orderBy[1].dir).to.equal('asc'); + expect(target.orderBy[1].field.canonicalString()).to.equal('__name__'); + }); + + it('generated order-bys are not affected by previously memoized targets', () => { + const col = newQueryForPath(ResourcePath.fromString('collection')); + + // Build two identical queries + const query1 = queryWithAddedFilter(col, filter('foo', '>', 1)); + const query2 = queryWithAddedFilter(col, filter('foo', '>', 1)); + + // query1 - first to aggregate target, then to non-aggregate target + const aggregateTarget1 = queryToAggregateTarget(query1); + const target1 = queryToTarget(query1); + + // query2 - first to non-aggregate target, then to aggregate target + const target2 = queryToTarget(query2); + const aggregateTarget2 = queryToAggregateTarget(query2); + + expect(aggregateTarget1.orderBy.length).to.equal(0); + + expect(aggregateTarget2.orderBy.length).to.equal(0); + + expect(target1.orderBy.length).to.equal(2); + expect(target1.orderBy[0].dir).to.equal('asc'); + expect(target1.orderBy[0].field.canonicalString()).to.equal('foo'); + expect(target1.orderBy[1].dir).to.equal('asc'); + expect(target1.orderBy[1].field.canonicalString()).to.equal('__name__'); + + expect(target2.orderBy.length).to.equal(2); + expect(target2.orderBy[0].dir).to.equal('asc'); + expect(target2.orderBy[0].field.canonicalString()).to.equal('foo'); + expect(target2.orderBy[1].dir).to.equal('asc'); + expect(target2.orderBy[1].field.canonicalString()).to.equal('__name__'); + }); + function assertQueryMatches( query: Query, matching: MutableDocument[], @@ -866,7 +918,7 @@ describe('Query', () => { } function assertImplicitOrderBy(query: Query, ...orderBys: OrderBy[]): void { - expect(queryOrderBy(query)).to.deep.equal(orderBys); + expect(queryNormalizedOrderBy(query)).to.deep.equal(orderBys); } function assertCanonicalId(query: Query, expectedCanonicalId: string): void { From f9a232a2970e2f9b8919b6f2fe167e16dcb18d04 Mon Sep 17 00:00:00 2001 From: renkelvin Date: Fri, 4 Aug 2023 09:43:44 -0700 Subject: [PATCH 05/15] Separate the implementation of initializeRecaptchaConfig (#7515) --- packages/auth/src/core/auth/auth_impl.test.ts | 9 ++++---- packages/auth/src/core/auth/auth_impl.ts | 23 +------------------ .../auth/src/core/credentials/email.test.ts | 7 +++--- packages/auth/src/core/index.ts | 5 ++-- .../strategies/email_and_password.test.ts | 13 ++++++----- .../src/core/strategies/email_link.test.ts | 9 ++++---- packages/auth/src/model/auth.ts | 1 - .../recaptcha_enterprise_verifier.ts | 21 +++++++++++++++++ 8 files changed, 45 insertions(+), 43 deletions(-) diff --git a/packages/auth/src/core/auth/auth_impl.test.ts b/packages/auth/src/core/auth/auth_impl.test.ts index 55fb6a65a5b..cca01efa54e 100644 --- a/packages/auth/src/core/auth/auth_impl.test.ts +++ b/packages/auth/src/core/auth/auth_impl.test.ts @@ -40,6 +40,7 @@ import * as navigator from '../util/navigator'; import * as reload from '../user/reload'; import { AuthImpl, DefaultConfig } from './auth_impl'; import { _initializeAuthInstance } from './initialize'; +import { _initializeRecaptchaConfig } from '../../platform_browser/recaptcha/recaptcha_enterprise_verifier'; import { ClientPlatform } from '../util/version'; import { mockEndpointWithParams } from '../../../test/helpers/api/helper'; import { Endpoint, RecaptchaClientType, RecaptchaVersion } from '../../api'; @@ -734,7 +735,7 @@ describe('core/auth/auth_impl', () => { }, recaptchaConfigResponseEnforce ); - await auth.initializeRecaptchaConfig(); + await _initializeRecaptchaConfig(auth); expect(auth._getRecaptchaConfig()).to.eql(cachedRecaptchaConfigEnforce); }); @@ -751,7 +752,7 @@ describe('core/auth/auth_impl', () => { }, recaptchaConfigResponseOff ); - await auth.initializeRecaptchaConfig(); + await _initializeRecaptchaConfig(auth); expect(auth._getRecaptchaConfig()).to.eql(cachedRecaptchaConfigOFF); }); @@ -767,7 +768,7 @@ describe('core/auth/auth_impl', () => { }, recaptchaConfigResponseEnforce ); - await auth.initializeRecaptchaConfig(); + await _initializeRecaptchaConfig(auth); auth.tenantId = 'tenant-id'; mockEndpointWithParams( Endpoint.GET_RECAPTCHA_CONFIG, @@ -778,7 +779,7 @@ describe('core/auth/auth_impl', () => { }, recaptchaConfigResponseOff ); - await auth.initializeRecaptchaConfig(); + await _initializeRecaptchaConfig(auth); auth.tenantId = null; expect(auth._getRecaptchaConfig()).to.eql(cachedRecaptchaConfigEnforce); diff --git a/packages/auth/src/core/auth/auth_impl.ts b/packages/auth/src/core/auth/auth_impl.ts index be4c3b2d7c7..c991d7e9b50 100644 --- a/packages/auth/src/core/auth/auth_impl.ts +++ b/packages/auth/src/core/auth/auth_impl.ts @@ -61,9 +61,7 @@ import { _assert } from '../util/assert'; import { _getInstance } from '../util/instantiator'; import { _getUserLanguage } from '../util/navigator'; import { _getClientVersion } from '../util/version'; -import { HttpHeader, RecaptchaClientType, RecaptchaVersion } from '../../api'; -import { getRecaptchaConfig } from '../../api/authentication/recaptcha'; -import { RecaptchaEnterpriseVerifier } from '../../platform_browser/recaptcha/recaptcha_enterprise_verifier'; +import { HttpHeader } from '../../api'; import { AuthMiddlewareQueue } from './middleware'; import { RecaptchaConfig } from '../../platform_browser/recaptcha/recaptcha'; import { _logWarn } from '../util/log'; @@ -395,25 +393,6 @@ export class AuthImpl implements AuthInternal, _FirebaseService { }); } - async initializeRecaptchaConfig(): Promise { - const response = await getRecaptchaConfig(this, { - clientType: RecaptchaClientType.WEB, - version: RecaptchaVersion.ENTERPRISE - }); - - const config = new RecaptchaConfig(response); - if (this.tenantId == null) { - this._agentRecaptchaConfig = config; - } else { - this._tenantRecaptchaConfigs[this.tenantId] = config; - } - - if (config.emailPasswordEnabled) { - const verifier = new RecaptchaEnterpriseVerifier(this); - void verifier.verify(); - } - } - _getRecaptchaConfig(): RecaptchaConfig | null { if (this.tenantId == null) { return this._agentRecaptchaConfig; diff --git a/packages/auth/src/core/credentials/email.test.ts b/packages/auth/src/core/credentials/email.test.ts index bcf14ca437a..f3e6ae4966f 100644 --- a/packages/auth/src/core/credentials/email.test.ts +++ b/packages/auth/src/core/credentials/email.test.ts @@ -38,6 +38,7 @@ import { EmailAuthCredential } from './email'; import { MockGreCAPTCHATopLevel } from '../../platform_browser/recaptcha/recaptcha_mock'; import * as jsHelpers from '../../platform_browser/load_js'; import { ServerError } from '../../api/errors'; +import { _initializeRecaptchaConfig } from '../../platform_browser/recaptcha/recaptcha_enterprise_verifier'; use(chaiAsPromised); @@ -135,7 +136,7 @@ describe('core/credentials/email', () => { }, recaptchaConfigResponseEnforce ); - await auth.initializeRecaptchaConfig(); + await _initializeRecaptchaConfig(auth); const idTokenResponse = await credential._getIdTokenResponse(auth); expect(idTokenResponse.idToken).to.eq('id-token'); @@ -169,7 +170,7 @@ describe('core/credentials/email', () => { }, recaptchaConfigResponseOff ); - await auth.initializeRecaptchaConfig(); + await _initializeRecaptchaConfig(auth); const idTokenResponse = await credential._getIdTokenResponse(auth); expect(idTokenResponse.idToken).to.eq('id-token'); @@ -202,7 +203,7 @@ describe('core/credentials/email', () => { }, recaptchaConfigResponseEnforce ); - await auth.initializeRecaptchaConfig(); + await _initializeRecaptchaConfig(auth); auth._agentRecaptchaConfig!.siteKey = 'cached-site-key'; await expect(credential._getIdTokenResponse(auth)).to.be.rejectedWith( diff --git a/packages/auth/src/core/index.ts b/packages/auth/src/core/index.ts index e39dbab52fb..6295a75ab85 100644 --- a/packages/auth/src/core/index.ts +++ b/packages/auth/src/core/index.ts @@ -25,7 +25,7 @@ import { ErrorFn, Unsubscribe } from '../model/public_types'; -import { _castAuth } from '../core/auth/auth_impl'; +import { _initializeRecaptchaConfig } from '../platform_browser/recaptcha/recaptcha_enterprise_verifier'; export { debugErrorMap, @@ -92,8 +92,7 @@ export function setPersistence( * @public */ export function initializeRecaptchaConfig(auth: Auth): Promise { - const authInternal = _castAuth(auth); - return authInternal.initializeRecaptchaConfig(); + return _initializeRecaptchaConfig(auth); } /** diff --git a/packages/auth/src/core/strategies/email_and_password.test.ts b/packages/auth/src/core/strategies/email_and_password.test.ts index fd9389aefc3..ed43e943b44 100644 --- a/packages/auth/src/core/strategies/email_and_password.test.ts +++ b/packages/auth/src/core/strategies/email_and_password.test.ts @@ -50,6 +50,7 @@ import { verifyPasswordResetCode } from './email_and_password'; import { MockGreCAPTCHATopLevel } from '../../platform_browser/recaptcha/recaptcha_mock'; +import { _initializeRecaptchaConfig } from '../../platform_browser/recaptcha/recaptcha_enterprise_verifier'; use(chaiAsPromised); use(sinonChai); @@ -202,7 +203,7 @@ describe('core/strategies/sendPasswordResetEmail', () => { }, recaptchaConfigResponseEnforce ); - await auth.initializeRecaptchaConfig(); + await _initializeRecaptchaConfig(auth); const apiMock = mockEndpoint(Endpoint.SEND_OOB_CODE, { email @@ -230,7 +231,7 @@ describe('core/strategies/sendPasswordResetEmail', () => { }, recaptchaConfigResponseOff ); - await auth.initializeRecaptchaConfig(); + await _initializeRecaptchaConfig(auth); const apiMock = mockEndpoint(Endpoint.SEND_OOB_CODE, { email @@ -299,7 +300,7 @@ describe('core/strategies/sendPasswordResetEmail', () => { }, recaptchaConfigResponseEnforce ); - await auth.initializeRecaptchaConfig(); + await _initializeRecaptchaConfig(auth); mockEndpoint(Endpoint.SEND_OOB_CODE, { email }); const response = await sendPasswordResetEmail(auth, email); @@ -617,7 +618,7 @@ describe('core/strategies/email_and_password/createUserWithEmailAndPassword', () }, recaptchaConfigResponseEnforce ); - await auth.initializeRecaptchaConfig(); + await _initializeRecaptchaConfig(auth); const { _tokenResponse, user, operationType } = (await createUserWithEmailAndPassword( @@ -648,7 +649,7 @@ describe('core/strategies/email_and_password/createUserWithEmailAndPassword', () }, recaptchaConfigResponseEnforce ); - await auth.initializeRecaptchaConfig(); + await _initializeRecaptchaConfig(auth); const { _tokenResponse, user, operationType } = (await createUserWithEmailAndPassword( @@ -726,7 +727,7 @@ describe('core/strategies/email_and_password/createUserWithEmailAndPassword', () }, recaptchaConfigResponseEnforce ); - await auth.initializeRecaptchaConfig(); + await _initializeRecaptchaConfig(auth); const { _tokenResponse, user, operationType } = (await createUserWithEmailAndPassword( diff --git a/packages/auth/src/core/strategies/email_link.test.ts b/packages/auth/src/core/strategies/email_link.test.ts index 4815205c916..7358b5a3512 100644 --- a/packages/auth/src/core/strategies/email_link.test.ts +++ b/packages/auth/src/core/strategies/email_link.test.ts @@ -46,6 +46,7 @@ import { } from './email_link'; import { MockGreCAPTCHATopLevel } from '../../platform_browser/recaptcha/recaptcha_mock'; import * as jsHelpers from '../../platform_browser/load_js'; +import { _initializeRecaptchaConfig } from '../../platform_browser/recaptcha/recaptcha_enterprise_verifier'; use(chaiAsPromised); use(sinonChai); @@ -210,7 +211,7 @@ describe('core/strategies/sendSignInLinkToEmail', () => { }, recaptchaConfigResponseEnforce ); - await auth.initializeRecaptchaConfig(); + await _initializeRecaptchaConfig(auth); const apiMock = mockEndpoint(Endpoint.SEND_OOB_CODE, { email @@ -239,7 +240,7 @@ describe('core/strategies/sendSignInLinkToEmail', () => { }, recaptchaConfigResponseOff ); - await auth.initializeRecaptchaConfig(); + await _initializeRecaptchaConfig(auth); const apiMock = mockEndpoint(Endpoint.SEND_OOB_CODE, { email @@ -282,7 +283,7 @@ describe('core/strategies/sendSignInLinkToEmail', () => { }, recaptchaConfigResponseEnforce ); - await auth.initializeRecaptchaConfig(); + await _initializeRecaptchaConfig(auth); auth._agentRecaptchaConfig!.siteKey = 'wrong-site-key'; mockEndpoint(Endpoint.SEND_OOB_CODE, { @@ -352,7 +353,7 @@ describe('core/strategies/sendSignInLinkToEmail', () => { }, recaptchaConfigResponseEnforce ); - await auth.initializeRecaptchaConfig(); + await _initializeRecaptchaConfig(auth); mockEndpoint(Endpoint.SEND_OOB_CODE, { email }); const response = await sendSignInLinkToEmail(auth, email, { diff --git a/packages/auth/src/model/auth.ts b/packages/auth/src/model/auth.ts index d2e50d92b58..4c82539dc97 100644 --- a/packages/auth/src/model/auth.ts +++ b/packages/auth/src/model/auth.ts @@ -97,5 +97,4 @@ export interface AuthInternal extends Auth { useDeviceLanguage(): void; signOut(): Promise; - initializeRecaptchaConfig(): Promise; } diff --git a/packages/auth/src/platform_browser/recaptcha/recaptcha_enterprise_verifier.ts b/packages/auth/src/platform_browser/recaptcha/recaptcha_enterprise_verifier.ts index dd7e4225a68..c2c0303088a 100644 --- a/packages/auth/src/platform_browser/recaptcha/recaptcha_enterprise_verifier.ts +++ b/packages/auth/src/platform_browser/recaptcha/recaptcha_enterprise_verifier.ts @@ -174,3 +174,24 @@ export async function injectRecaptchaFields( }); return newRequest; } + +export async function _initializeRecaptchaConfig(auth: Auth): Promise { + const authInternal = _castAuth(auth); + + const response = await getRecaptchaConfig(authInternal, { + clientType: RecaptchaClientType.WEB, + version: RecaptchaVersion.ENTERPRISE + }); + + const config = new RecaptchaConfig(response); + if (authInternal.tenantId == null) { + authInternal._agentRecaptchaConfig = config; + } else { + authInternal._tenantRecaptchaConfigs[authInternal.tenantId] = config; + } + + if (config.emailPasswordEnabled) { + const verifier = new RecaptchaEnterpriseVerifier(authInternal); + void verifier.verify(); + } +} From 0038e116b68dc22190a15d99115ff9e904cbd0c5 Mon Sep 17 00:00:00 2001 From: wu-hui <53845758+wu-hui@users.noreply.github.com> Date: Fri, 4 Aug 2023 13:28:21 -0400 Subject: [PATCH 06/15] Setup firestore tests for named db (#7505) --- packages/firestore/karma.conf.js | 7 ++++ packages/firestore/package.json | 8 +++-- packages/firestore/scripts/run-tests.ts | 7 ++++ .../test/integration/api/aggregation.test.ts | 32 ++++++++++++++----- .../test/integration/api/bundle.test.ts | 4 ++- .../test/integration/api/database.test.ts | 7 ++-- .../test/integration/api/query.test.ts | 8 +++-- .../test/integration/api/validation.test.ts | 10 ++++-- .../test/integration/util/helpers.ts | 7 +++- .../test/integration/util/settings.ts | 15 +++++++++ packages/firestore/test/lite/helpers.ts | 5 +-- .../firestore/test/lite/integration.test.ts | 32 +++++++++++++------ 12 files changed, 110 insertions(+), 32 deletions(-) diff --git a/packages/firestore/karma.conf.js b/packages/firestore/karma.conf.js index c09c5375b7a..d9227d7623a 100644 --- a/packages/firestore/karma.conf.js +++ b/packages/firestore/karma.conf.js @@ -46,6 +46,13 @@ module.exports = function (config) { }; } + if (argv.databaseId) { + karmaConfig.client = { + ...karmaConfig.client, + databaseId: argv.databaseId + }; + } + config.set(karmaConfig); }; diff --git a/packages/firestore/package.json b/packages/firestore/package.json index ecdebe9d013..62e305cf5ec 100644 --- a/packages/firestore/package.json +++ b/packages/firestore/package.json @@ -23,21 +23,25 @@ "prettier": "prettier --write '*.js' '@(lite|src|test)/**/*.ts' 'test/unit/remote/bloom_filter_golden_test_data/*.json'", "test:lite": "ts-node ./scripts/run-tests.ts --emulator --platform node_lite --main=lite/index.ts 'test/lite/**/*.test.ts'", "test:lite:prod": "ts-node ./scripts/run-tests.ts --platform node_lite --main=lite/index.ts 'test/lite/**/*.test.ts'", + "test:lite:prod:nameddb": "ts-node ./scripts/run-tests.ts --platform node_lite --databaseId=test-db --main=lite/index.ts 'test/lite/**/*.test.ts'", "test:lite:browser": "karma start --single-run --lite", + "test:lite:browser:nameddb": "karma start --single-run --lite --databaseId=test-db", "test:lite:browser:debug": "karma start --browsers=Chrome --lite --auto-watch", "test": "run-s lint test:all", "test:ci": "node ../../scripts/run_tests_in_ci.js -s test:all:ci", - "test:all:ci": "run-p test:browser test:lite:browser test:travis", - "test:all": "run-p test:browser test:lite:browser test:travis test:minified", + "test:all:ci": "run-s test:browser test:travis test:lite:browser test:browser:prod:nameddb test:lite:browser:nameddb", + "test:all": "run-p test:browser test:lite:browser test:travis test:minified test:browser:prod:nameddb test:lite:browser:nameddb", "test:browser": "karma start --single-run", "test:browser:emulator:debug": "karma start --browsers=Chrome --targetBackend=emulator", "test:browser:emulator": "karma start --single-run --targetBackend=emulator", "test:browser:nightly": "karma start --single-run --targetBackend=nightly", "test:browser:prod": "karma start --single-run --targetBackend=prod", + "test:browser:prod:nameddb": "karma start --single-run --targetBackend=prod --databaseId=test-db", "test:browser:unit": "karma start --single-run --unit", "test:browser:debug": "karma start --browsers=Chrome --auto-watch", "test:node": "ts-node ./scripts/run-tests.ts --main=test/register.ts --emulator 'test/{,!(browser|lite)/**/}*.test.ts'", "test:node:prod": "ts-node ./scripts/run-tests.ts --main=test/register.ts 'test/{,!(browser|lite)/**/}*.test.ts'", + "test:node:prod:nameddb": "ts-node ./scripts/run-tests.ts --main=test/register.ts --databaseId=test-db 'test/{,!(browser|lite)/**/}*.test.ts'", "test:node:persistence": "ts-node ./scripts/run-tests.ts --main=test/register.ts --persistence --emulator 'test/{,!(browser|lite)/**/}*.test.ts'", "test:node:persistence:prod": "ts-node ./scripts/run-tests.ts --main=test/register.ts --persistence 'test/{,!(browser|lite)/**/}*.test.ts'", "test:travis": "ts-node --compiler-options='{\"module\":\"commonjs\"}' ../../scripts/emulator-testing/firestore-test-runner.ts", diff --git a/packages/firestore/scripts/run-tests.ts b/packages/firestore/scripts/run-tests.ts index a3b94aa9eae..7e5cdf8fc80 100644 --- a/packages/firestore/scripts/run-tests.ts +++ b/packages/firestore/scripts/run-tests.ts @@ -34,6 +34,9 @@ const argv = yargs.options({ }, persistence: { type: 'boolean' + }, + databaseId: { + type: 'string' } }).parseSync(); @@ -66,6 +69,10 @@ if (argv.persistence) { args.push('--require', 'test/util/node_persistence.ts'); } +if (argv.databaseId) { + process.env.FIRESTORE_TARGET_DB_ID = argv.databaseId; +} + args = args.concat(argv._ as string[]); const childProcess = spawn(nyc, args, { diff --git a/packages/firestore/test/integration/api/aggregation.test.ts b/packages/firestore/test/integration/api/aggregation.test.ts index 319e22f31a4..657f7f3688d 100644 --- a/packages/firestore/test/integration/api/aggregation.test.ts +++ b/packages/firestore/test/integration/api/aggregation.test.ts @@ -139,9 +139,15 @@ apiDescribe('Count queries', persistence => { where('key1', '==', 42), where('key2', '<', 42) ); - await expect(getCountFromServer(query_)).to.be.eventually.rejectedWith( - /index.*https:\/\/console\.firebase\.google\.com/ - ); + if (coll.firestore._databaseId.isDefaultDatabase) { + await expect( + getCountFromServer(query_) + ).to.be.eventually.rejectedWith( + /index.*https:\/\/console\.firebase\.google\.com/ + ); + } else { + await expect(getCountFromServer(query_)).to.be.eventually.rejected; + } }); } ); @@ -343,11 +349,21 @@ apiDescribe('Aggregation queries', persistence => { where('key1', '==', 42), where('key2', '<', 42) ); - await expect( - getAggregateFromServer(query_, { count: count() }) - ).to.be.eventually.rejectedWith( - /index.*https:\/\/console\.firebase\.google\.com/ - ); + if (coll.firestore._databaseId.isDefaultDatabase) { + await expect( + getAggregateFromServer(query_, { + count: count() + }) + ).to.be.eventually.rejectedWith( + /index.*https:\/\/console\.firebase\.google\.com/ + ); + } else { + await expect( + getAggregateFromServer(query_, { + count: count() + }) + ).to.be.eventually.rejected; + } }); } ); diff --git a/packages/firestore/test/integration/api/bundle.test.ts b/packages/firestore/test/integration/api/bundle.test.ts index 96d186bba1b..f06a8b4c7b8 100644 --- a/packages/firestore/test/integration/api/bundle.test.ts +++ b/packages/firestore/test/integration/api/bundle.test.ts @@ -85,7 +85,9 @@ apiDescribe('Bundles', persistence => { const projectId: string = db.app.options.projectId!; // Extract elements from BUNDLE_TEMPLATE and replace the project ID. - const elements = BUNDLE_TEMPLATE.map(e => e.replace('{0}', projectId)); + const elements = BUNDLE_TEMPLATE.map(e => + e.replace('{0}', projectId).replace('(default)', db._databaseId.database) + ); // Recalculating length prefixes for elements that are not BundleMetadata. let bundleContent = ''; diff --git a/packages/firestore/test/integration/api/database.test.ts b/packages/firestore/test/integration/api/database.test.ts index 76194e3ea6b..f7b1f9cd250 100644 --- a/packages/firestore/test/integration/api/database.test.ts +++ b/packages/firestore/test/integration/api/database.test.ts @@ -1114,7 +1114,8 @@ apiDescribe('Database', persistence => { const firestore2 = newTestFirestore( newTestApp(options.projectId!, name), - DEFAULT_SETTINGS + DEFAULT_SETTINGS, + firestore._databaseId.database ); await enableIndexedDbPersistence(firestore2); await waitForPendingWrites(firestore2); @@ -1157,7 +1158,9 @@ apiDescribe('Database', persistence => { await deleteApp(app); const firestore2 = newTestFirestore( - newTestApp(options.projectId!, name) + newTestApp(options.projectId!, name), + undefined, + docRef.firestore._databaseId.database ); await enableIndexedDbPersistence(firestore2); const docRef2 = doc(firestore2, docRef.path); diff --git a/packages/firestore/test/integration/api/query.test.ts b/packages/firestore/test/integration/api/query.test.ts index a388939452b..92662596bc7 100644 --- a/packages/firestore/test/integration/api/query.test.ts +++ b/packages/firestore/test/integration/api/query.test.ts @@ -682,9 +682,11 @@ apiDescribe('Queries', persistence => { err => { expect(err.code).to.equal('failed-precondition'); expect(err.message).to.exist; - expect(err.message).to.match( - /index.*https:\/\/console\.firebase\.google\.com/ - ); + if (coll.firestore._databaseId.isDefaultDatabase) { + expect(err.message).to.match( + /index.*https:\/\/console\.firebase\.google\.com/ + ); + } deferred.resolve(); } ); diff --git a/packages/firestore/test/integration/api/validation.test.ts b/packages/firestore/test/integration/api/validation.test.ts index 1aaf70e9452..d36f49147b6 100644 --- a/packages/firestore/test/integration/api/validation.test.ts +++ b/packages/firestore/test/integration/api/validation.test.ts @@ -61,7 +61,11 @@ import { withTestCollection, withTestDb } from '../util/helpers'; -import { ALT_PROJECT_ID, DEFAULT_PROJECT_ID } from '../util/settings'; +import { + ALT_PROJECT_ID, + DEFAULT_PROJECT_ID, + TARGET_DB_ID +} from '../util/settings'; // We're using 'as any' to pass invalid values to APIs for testing purposes. /* eslint-disable @typescript-eslint/no-explicit-any */ @@ -445,8 +449,8 @@ apiDescribe('Validation:', persistence => { db, data, `Document reference is for database ` + - `${ALT_PROJECT_ID}/(default) but should be for database ` + - `${DEFAULT_PROJECT_ID}/(default) (found in field ` + + `${ALT_PROJECT_ID}/${TARGET_DB_ID} but should be for database ` + + `${DEFAULT_PROJECT_ID}/${TARGET_DB_ID} (found in field ` + `foo)` ); }); diff --git a/packages/firestore/test/integration/util/helpers.ts b/packages/firestore/test/integration/util/helpers.ts index 2fe10cb3f2e..3b27e5e7c47 100644 --- a/packages/firestore/test/integration/util/helpers.ts +++ b/packages/firestore/test/integration/util/helpers.ts @@ -45,6 +45,7 @@ import { ALT_PROJECT_ID, DEFAULT_PROJECT_ID, DEFAULT_SETTINGS, + TARGET_DB_ID, USE_EMULATOR } from './settings'; @@ -295,7 +296,11 @@ export async function withTestDbsSettings( if (persistence !== PERSISTENCE_MODE_UNSPECIFIED) { newSettings.localCache = persistence.asLocalCacheFirestoreSettings(); } - const db = newTestFirestore(newTestApp(projectId), newSettings); + const db = newTestFirestore( + newTestApp(projectId), + newSettings, + TARGET_DB_ID + ); dbs.push(db); } diff --git a/packages/firestore/test/integration/util/settings.ts b/packages/firestore/test/integration/util/settings.ts index 521ed30870f..e57c896d977 100644 --- a/packages/firestore/test/integration/util/settings.ts +++ b/packages/firestore/test/integration/util/settings.ts @@ -35,6 +35,8 @@ enum TargetBackend { // eslint-disable-next-line @typescript-eslint/no-require-imports const PROJECT_CONFIG = require('../../../../../config/project.json'); +export const TARGET_DB_ID: string | '(default)' = getTargetDbId(); + const TARGET_BACKEND: TargetBackend = getTargetBackend(); export const USE_EMULATOR: boolean = TARGET_BACKEND === TargetBackend.EMULATOR; @@ -46,6 +48,19 @@ export const DEFAULT_SETTINGS: PrivateSettings = { // eslint-disable-next-line no-console console.log(`Default Settings: ${JSON.stringify(DEFAULT_SETTINGS)}`); +// eslint-disable-next-line no-console +console.log(`Default DatabaseId: ${JSON.stringify(TARGET_DB_ID)}`); + +function getTargetDbId(): string | '(default)' { + const karma = typeof __karma__ !== 'undefined' ? __karma__ : undefined; + if (karma && karma.config.databaseId) { + return karma.config.databaseId; + } + if (process.env.FIRESTORE_TARGET_DB_ID) { + return process.env.FIRESTORE_TARGET_DB_ID; + } + return '(default)'; +} function parseTargetBackend(targetBackend: string): TargetBackend { switch (targetBackend) { diff --git a/packages/firestore/test/lite/helpers.ts b/packages/firestore/test/lite/helpers.ts index e51c7c606ab..4dbd43ab955 100644 --- a/packages/firestore/test/lite/helpers.ts +++ b/packages/firestore/test/lite/helpers.ts @@ -35,7 +35,8 @@ import { QueryDocumentSnapshot } from '../../src/lite-api/snapshot'; import { AutoId } from '../../src/util/misc'; import { DEFAULT_PROJECT_ID, - DEFAULT_SETTINGS + DEFAULT_SETTINGS, + TARGET_DB_ID } from '../integration/util/settings'; let appCount = 0; @@ -50,7 +51,7 @@ export async function withTestDbSettings( 'test-app-' + appCount++ ); - const firestore = initializeFirestore(app, settings); + const firestore = initializeFirestore(app, settings, TARGET_DB_ID); return fn(firestore); } diff --git a/packages/firestore/test/lite/integration.test.ts b/packages/firestore/test/lite/integration.test.ts index 8dee373a867..e86fdb9f6aa 100644 --- a/packages/firestore/test/lite/integration.test.ts +++ b/packages/firestore/test/lite/integration.test.ts @@ -2404,9 +2404,13 @@ describe('Count queries', () => { where('key1', '==', 42), where('key2', '<', 42) ); - await expect(getCount(query_)).to.be.eventually.rejectedWith( - /index.*https:\/\/console\.firebase\.google\.com/ - ); + if (coll.firestore._databaseId.isDefaultDatabase) { + await expect(getCount(query_)).to.be.eventually.rejectedWith( + /index.*https:\/\/console\.firebase\.google\.com/ + ); + } else { + await expect(getCount(query_)).to.be.eventually.rejected; + } }); } ); @@ -2707,13 +2711,21 @@ describe('Aggregate queries', () => { where('key1', '==', 42), where('key2', '<', 42) ); - await expect( - getAggregate(query_, { - myCount: count() - }) - ).to.be.eventually.rejectedWith( - /index.*https:\/\/console\.firebase\.google\.com/ - ); + if (coll.firestore._databaseId.isDefaultDatabase) { + await expect( + getAggregate(query_, { + myCount: count() + }) + ).to.be.eventually.rejectedWith( + /index.*https:\/\/console\.firebase\.google\.com/ + ); + } else { + await expect( + getAggregate(query_, { + myCount: count() + }) + ).to.be.eventually.rejected; + } }); } ); From c9e2b0b8cd5fd0db3cac7bc3a00629ae34302189 Mon Sep 17 00:00:00 2001 From: Chase Hartsell <51487099+ch5zzy@users.noreply.github.com> Date: Mon, 7 Aug 2023 11:54:25 -0700 Subject: [PATCH 07/15] Add support for validating passwords against the password policy in auth (#7514) * Implement validatePassword endpoint for public API with PasswordPolicy and PasswordValidationStatus public types * Update auth demo to include a section for password validation --- .changeset/thick-lions-divide.md | 6 + common/api-review/auth.api.md | 30 + docs-devsite/auth.md | 36 + docs-devsite/auth.passwordpolicy.md | 75 ++ docs-devsite/auth.passwordvalidationstatus.md | 112 +++ packages/auth/README.md | 29 +- packages/auth/demo/public/index.html | 102 ++- packages/auth/demo/public/style.css | 21 +- packages/auth/demo/src/index.js | 166 +++- packages/auth/karma.conf.js | 5 +- packages/auth/src/api/errors.ts | 5 +- packages/auth/src/api/index.ts | 3 +- .../get_password_policy.test.ts | 90 +++ .../password_policy/get_password_policy.ts | 71 ++ packages/auth/src/core/auth/auth_impl.test.ts | 252 ++++++ packages/auth/src/core/auth/auth_impl.ts | 57 +- .../core/auth/password_policy_impl.test.ts | 429 ++++++++++ .../src/core/auth/password_policy_impl.ts | 192 +++++ packages/auth/src/core/errors.ts | 10 +- packages/auth/src/core/index.ts | 35 +- .../strategies/email_and_password.test.ts | 743 ++++++++++++++++-- .../src/core/strategies/email_and_password.ts | 61 +- packages/auth/src/model/auth.ts | 8 + packages/auth/src/model/password_policy.ts | 116 +++ packages/auth/src/model/public_types.ts | 90 +++ .../auth/test/helpers/integration/helpers.ts | 35 + .../test/integration/flows/anonymous.test.ts | 20 +- .../auth/test/integration/flows/email.test.ts | 39 +- .../flows/middleware_test_generator.ts | 12 +- .../integration/flows/password_policy.test.ts | 119 +++ 30 files changed, 2864 insertions(+), 105 deletions(-) create mode 100644 .changeset/thick-lions-divide.md create mode 100644 docs-devsite/auth.passwordpolicy.md create mode 100644 docs-devsite/auth.passwordvalidationstatus.md create mode 100644 packages/auth/src/api/password_policy/get_password_policy.test.ts create mode 100644 packages/auth/src/api/password_policy/get_password_policy.ts create mode 100644 packages/auth/src/core/auth/password_policy_impl.test.ts create mode 100644 packages/auth/src/core/auth/password_policy_impl.ts create mode 100644 packages/auth/src/model/password_policy.ts create mode 100644 packages/auth/test/integration/flows/password_policy.test.ts diff --git a/.changeset/thick-lions-divide.md b/.changeset/thick-lions-divide.md new file mode 100644 index 00000000000..546c99c8418 --- /dev/null +++ b/.changeset/thick-lions-divide.md @@ -0,0 +1,6 @@ +--- +'@firebase/auth': minor +'firebase': minor +--- + +Add a validatePassword method for validating passwords against the password policy configured for the project or a tenant. This method returns a status object that can be used to display the requirements of the password policy and whether each one was met. diff --git a/common/api-review/auth.api.md b/common/api-review/auth.api.md index bfd776e1562..8603b7a8ec5 100644 --- a/common/api-review/auth.api.md +++ b/common/api-review/auth.api.md @@ -563,6 +563,33 @@ export interface ParsedToken { 'sub'?: string; } +// @public +export interface PasswordPolicy { + readonly allowedNonAlphanumericCharacters: string; + readonly customStrengthOptions: { + readonly minPasswordLength?: number; + readonly maxPasswordLength?: number; + readonly containsLowercaseLetter?: boolean; + readonly containsUppercaseLetter?: boolean; + readonly containsNumericCharacter?: boolean; + readonly containsNonAlphanumericCharacter?: boolean; + }; + readonly enforcementState: string; + readonly forceUpgradeOnSignin: boolean; +} + +// @public +export interface PasswordValidationStatus { + readonly containsLowercaseLetter?: boolean; + readonly containsNonAlphanumericCharacter?: boolean; + readonly containsNumericCharacter?: boolean; + readonly containsUppercaseLetter?: boolean; + readonly isValid: boolean; + readonly meetsMaxPasswordLength?: boolean; + readonly meetsMinPasswordLength?: boolean; + readonly passwordPolicy: PasswordPolicy; +} + // @public export interface Persistence { readonly type: 'SESSION' | 'LOCAL' | 'NONE'; @@ -869,6 +896,9 @@ export interface UserMetadata { // @public export type UserProfile = Record; +// @public +export function validatePassword(auth: Auth, password: string): Promise; + // @public export function verifyBeforeUpdateEmail(user: User, newEmail: string, actionCodeSettings?: ActionCodeSettings | null): Promise; diff --git a/docs-devsite/auth.md b/docs-devsite/auth.md index 4b76e06ee49..6ff6f007484 100644 --- a/docs-devsite/auth.md +++ b/docs-devsite/auth.md @@ -49,6 +49,7 @@ Firebase Authentication | [signOut(auth)](./auth.md#signout) | Signs out the current user. | | [updateCurrentUser(auth, user)](./auth.md#updatecurrentuser) | Asynchronously sets the provided user as [Auth.currentUser](./auth.auth.md#authcurrentuser) on the [Auth](./auth.auth.md#auth_interface) instance. | | [useDeviceLanguage(auth)](./auth.md#usedevicelanguage) | Sets the current language to the default device/browser preference. | +| [validatePassword(auth, password)](./auth.md#validatepassword) | Validates the password against the password policy configured for the project or tenant. | | [verifyPasswordResetCode(auth, code)](./auth.md#verifypasswordresetcode) | Checks a password reset code sent to the user by email or other out-of-band mechanism. | | function(link...) | | [parseActionCodeURL(link)](./auth.md#parseactioncodeurl) | Parses the email action link string and returns an [ActionCodeURL](./auth.actioncodeurl.md#actioncodeurl_class) if the link is valid, otherwise returns null. | @@ -124,6 +125,8 @@ Firebase Authentication | [MultiFactorUser](./auth.multifactoruser.md#multifactoruser_interface) | An interface that defines the multi-factor related properties and operations pertaining to a [User](./auth.user.md#user_interface). | | [OAuthCredentialOptions](./auth.oauthcredentialoptions.md#oauthcredentialoptions_interface) | Defines the options for initializing an [OAuthCredential](./auth.oauthcredential.md#oauthcredential_class). | | [ParsedToken](./auth.parsedtoken.md#parsedtoken_interface) | Interface representing a parsed ID token. | +| [PasswordPolicy](./auth.passwordpolicy.md#passwordpolicy_interface) | A structure specifying password policy requirements. | +| [PasswordValidationStatus](./auth.passwordvalidationstatus.md#passwordvalidationstatus_interface) | A structure indicating which password policy requirements were met or violated and what the requirements are. | | [Persistence](./auth.persistence.md#persistence_interface) | An interface covering the possible persistence mechanism types. | | [PhoneMultiFactorAssertion](./auth.phonemultifactorassertion.md#phonemultifactorassertion_interface) | The class for asserting ownership of a phone second factor. Provided by [PhoneMultiFactorGenerator.assertion()](./auth.phonemultifactorgenerator.md#phonemultifactorgeneratorassertion). | | [PhoneMultiFactorEnrollInfoOptions](./auth.phonemultifactorenrollinfooptions.md#phonemultifactorenrollinfooptions_interface) | Options used for enrolling a second factor. | @@ -1077,6 +1080,39 @@ export declare function useDeviceLanguage(auth: Auth): void; void +## validatePassword() + +Validates the password against the password policy configured for the project or tenant. + +If no tenant ID is set on the `Auth` instance, then this method will use the password policy configured for the project. Otherwise, this method will use the policy configured for the tenant. If a password policy has not been configured, then the default policy configured for all projects will be used. + +If an auth flow fails because a submitted password does not meet the password policy requirements and this method has previously been called, then this method will use the most recent policy available when called again. + +Signature: + +```typescript +export declare function validatePassword(auth: Auth, password: string): Promise; +``` + +### Parameters + +| Parameter | Type | Description | +| --- | --- | --- | +| auth | [Auth](./auth.auth.md#auth_interface) | The [Auth](./auth.auth.md#auth_interface) instance. | +| password | string | The password to validate. | + +Returns: + +Promise<[PasswordValidationStatus](./auth.passwordvalidationstatus.md#passwordvalidationstatus_interface)> + +### Example + + +```javascript +validatePassword(auth, 'some-password'); + +``` + ## verifyPasswordResetCode() Checks a password reset code sent to the user by email or other out-of-band mechanism. diff --git a/docs-devsite/auth.passwordpolicy.md b/docs-devsite/auth.passwordpolicy.md new file mode 100644 index 00000000000..c1f6aae0745 --- /dev/null +++ b/docs-devsite/auth.passwordpolicy.md @@ -0,0 +1,75 @@ +Project: /docs/reference/js/_project.yaml +Book: /docs/reference/_book.yaml +page_type: reference + +{% comment %} +DO NOT EDIT THIS FILE! +This is generated by the JS SDK team, and any local changes will be +overwritten. Changes should be made in the source code at +https://github.com/firebase/firebase-js-sdk +{% endcomment %} + +# PasswordPolicy interface +A structure specifying password policy requirements. + +Signature: + +```typescript +export interface PasswordPolicy +``` + +## Properties + +| Property | Type | Description | +| --- | --- | --- | +| [allowedNonAlphanumericCharacters](./auth.passwordpolicy.md#passwordpolicyallowednonalphanumericcharacters) | string | List of characters that are considered non-alphanumeric during validation. | +| [customStrengthOptions](./auth.passwordpolicy.md#passwordpolicycustomstrengthoptions) | { readonly minPasswordLength?: number; readonly maxPasswordLength?: number; readonly containsLowercaseLetter?: boolean; readonly containsUppercaseLetter?: boolean; readonly containsNumericCharacter?: boolean; readonly containsNonAlphanumericCharacter?: boolean; } | Requirements enforced by this password policy. | +| [enforcementState](./auth.passwordpolicy.md#passwordpolicyenforcementstate) | string | The enforcement state of the policy. Can be 'OFF' or 'ENFORCE'. | +| [forceUpgradeOnSignin](./auth.passwordpolicy.md#passwordpolicyforceupgradeonsignin) | boolean | Whether existing passwords must meet the policy. | + +## PasswordPolicy.allowedNonAlphanumericCharacters + +List of characters that are considered non-alphanumeric during validation. + +Signature: + +```typescript +readonly allowedNonAlphanumericCharacters: string; +``` + +## PasswordPolicy.customStrengthOptions + +Requirements enforced by this password policy. + +Signature: + +```typescript +readonly customStrengthOptions: { + readonly minPasswordLength?: number; + readonly maxPasswordLength?: number; + readonly containsLowercaseLetter?: boolean; + readonly containsUppercaseLetter?: boolean; + readonly containsNumericCharacter?: boolean; + readonly containsNonAlphanumericCharacter?: boolean; + }; +``` + +## PasswordPolicy.enforcementState + +The enforcement state of the policy. Can be 'OFF' or 'ENFORCE'. + +Signature: + +```typescript +readonly enforcementState: string; +``` + +## PasswordPolicy.forceUpgradeOnSignin + +Whether existing passwords must meet the policy. + +Signature: + +```typescript +readonly forceUpgradeOnSignin: boolean; +``` diff --git a/docs-devsite/auth.passwordvalidationstatus.md b/docs-devsite/auth.passwordvalidationstatus.md new file mode 100644 index 00000000000..0b53e5e6c4f --- /dev/null +++ b/docs-devsite/auth.passwordvalidationstatus.md @@ -0,0 +1,112 @@ +Project: /docs/reference/js/_project.yaml +Book: /docs/reference/_book.yaml +page_type: reference + +{% comment %} +DO NOT EDIT THIS FILE! +This is generated by the JS SDK team, and any local changes will be +overwritten. Changes should be made in the source code at +https://github.com/firebase/firebase-js-sdk +{% endcomment %} + +# PasswordValidationStatus interface +A structure indicating which password policy requirements were met or violated and what the requirements are. + +Signature: + +```typescript +export interface PasswordValidationStatus +``` + +## Properties + +| Property | Type | Description | +| --- | --- | --- | +| [containsLowercaseLetter](./auth.passwordvalidationstatus.md#passwordvalidationstatuscontainslowercaseletter) | boolean | Whether the password contains a lowercase letter, or undefined if not required. | +| [containsNonAlphanumericCharacter](./auth.passwordvalidationstatus.md#passwordvalidationstatuscontainsnonalphanumericcharacter) | boolean | Whether the password contains a non-alphanumeric character, or undefined if not required. | +| [containsNumericCharacter](./auth.passwordvalidationstatus.md#passwordvalidationstatuscontainsnumericcharacter) | boolean | Whether the password contains a numeric character, or undefined if not required. | +| [containsUppercaseLetter](./auth.passwordvalidationstatus.md#passwordvalidationstatuscontainsuppercaseletter) | boolean | Whether the password contains an uppercase letter, or undefined if not required. | +| [isValid](./auth.passwordvalidationstatus.md#passwordvalidationstatusisvalid) | boolean | Whether the password meets all requirements. | +| [meetsMaxPasswordLength](./auth.passwordvalidationstatus.md#passwordvalidationstatusmeetsmaxpasswordlength) | boolean | Whether the password meets the maximum password length, or undefined if not required. | +| [meetsMinPasswordLength](./auth.passwordvalidationstatus.md#passwordvalidationstatusmeetsminpasswordlength) | boolean | Whether the password meets the minimum password length, or undefined if not required. | +| [passwordPolicy](./auth.passwordvalidationstatus.md#passwordvalidationstatuspasswordpolicy) | [PasswordPolicy](./auth.passwordpolicy.md#passwordpolicy_interface) | The policy used to validate the password. | + +## PasswordValidationStatus.containsLowercaseLetter + +Whether the password contains a lowercase letter, or undefined if not required. + +Signature: + +```typescript +readonly containsLowercaseLetter?: boolean; +``` + +## PasswordValidationStatus.containsNonAlphanumericCharacter + +Whether the password contains a non-alphanumeric character, or undefined if not required. + +Signature: + +```typescript +readonly containsNonAlphanumericCharacter?: boolean; +``` + +## PasswordValidationStatus.containsNumericCharacter + +Whether the password contains a numeric character, or undefined if not required. + +Signature: + +```typescript +readonly containsNumericCharacter?: boolean; +``` + +## PasswordValidationStatus.containsUppercaseLetter + +Whether the password contains an uppercase letter, or undefined if not required. + +Signature: + +```typescript +readonly containsUppercaseLetter?: boolean; +``` + +## PasswordValidationStatus.isValid + +Whether the password meets all requirements. + +Signature: + +```typescript +readonly isValid: boolean; +``` + +## PasswordValidationStatus.meetsMaxPasswordLength + +Whether the password meets the maximum password length, or undefined if not required. + +Signature: + +```typescript +readonly meetsMaxPasswordLength?: boolean; +``` + +## PasswordValidationStatus.meetsMinPasswordLength + +Whether the password meets the minimum password length, or undefined if not required. + +Signature: + +```typescript +readonly meetsMinPasswordLength?: boolean; +``` + +## PasswordValidationStatus.passwordPolicy + +The policy used to validate the password. + +Signature: + +```typescript +readonly passwordPolicy: PasswordPolicy; +``` diff --git a/packages/auth/README.md b/packages/auth/README.md index 6e737fe7729..a3a97f68356 100644 --- a/packages/auth/README.md +++ b/packages/auth/README.md @@ -54,7 +54,7 @@ firebase emulators:exec --project foo-bar --only auth "yarn test:integration:loc ### Integration testing with the production backend -Currently, MFA TOTP tests only run against the production backend (since they are not supported on the emulator yet). +Currently, MFA TOTP and password policy tests only run against the production backend (since they are not supported on the emulator yet). Running against the backend also makes it a more reliable end-to-end test. The TOTP tests require the following email/password combination to exist in the project, so if you are running this test against your test project, please create this user: @@ -71,6 +71,33 @@ curl -H "Authorization: Bearer $(gcloud auth print-access-token)" -H "Conten }' ``` +The password policy tests require a tenant configured with a password policy that requires all options to exist in the project. + +If you are running this test against your test project, please create the tenant and configure the policy with the following curl command: + +``` +curl -H "Authorization: Bearer $(gcloud auth print-access-token)" -H "Content-Type: application/json" -H "X-Goog-User-Project: ${PROJECT_ID}" -X POST https://identitytoolkit.googleapis.com/v2/projects/${PROJECT_ID}/tenants -d '{ + "displayName": "passpol-tenant", + "passwordPolicyConfig": { + "passwordPolicyEnforcementState": "ENFORCE", + "passwordPolicyVersions": [ + { + "customStrengthOptions": { + "minPasswordLength": 8, + "maxPasswordLength": 24, + "containsLowercaseCharacter": true, + "containsUppercaseCharacter": true, + "containsNumericCharacter": true, + "containsNonAlphanumericCharacter": true + } + } + ] + } + }' +``` + +Replace the tenant ID `passpol-tenant-d7hha` in [test/integration/flows/password_policy.test.ts](https://github.com/firebase/firebase-js-sdk/blob/master/packages/auth/test/integration/flows/password_policy.test.ts) with the ID for the newly created tenant. The tenant ID can be found at the end of the `name` property in the response and is in the format `passpol-tenant-xxxxx`. + ### Selenium Webdriver tests These tests assume that you have both Firefox and Chrome installed on your diff --git a/packages/auth/demo/public/index.html b/packages/auth/demo/public/index.html index cbac8d73d90..2b13bcc3a0e 100644 --- a/packages/auth/demo/public/index.html +++ b/packages/auth/demo/public/index.html @@ -252,8 +252,38 @@
- + +
+ +
+ +
+
+ These requirements are not currently enforced by the backend. Basic requirements are still enforced +
+
+ Password must be at least 6 characters. +
+
+ Password must be at most 4096 characters. +
+
+ Password must contain a lowercase letter. +
+
+ Password must contain an uppercase letter. +
+
+ Password must contain a numeric character. +
+
+ Password must contain a non-alphanumeric character. +
+
@@ -264,8 +294,41 @@
- + +
+ +
+ +
+
+ These requirements are not currently enforced by the backend. Basic requirements are still enforced. +
+
+ Existing passwords must meet these requirements. +
+
+ Password must be at least 6 characters. +
+
+ Password must be at most 4096 characters. +
+
+ Password must contain a lowercase letter. +
+
+ Password must contain an uppercase letter. +
+
+ Password must contain a numeric character. +
+
+ Password must contain a non-alphanumeric character. +
+
+ + +
+
+ These requirements are not currently enforced by the backend. Basic requirements are still enforced. +
+
+ Password must be at least 6 characters. +
+
+ Password must be at most 4096 characters. +
+
+ Password must contain a lowercase letter. +
+
+ Password must contain an uppercase letter. +
+
+ Password must contain a numeric character. +
+
+ Password must contain a non-alphanumeric character. +
+
+
Fetch Sign In Methods
diff --git a/packages/auth/demo/public/style.css b/packages/auth/demo/public/style.css index 1a7554ae618..d164e0486f7 100644 --- a/packages/auth/demo/public/style.css +++ b/packages/auth/demo/public/style.css @@ -76,6 +76,21 @@ body.user-info-displayed { border: none; } +.password-validation-requirements { + margin: 5px 0; + display: none; +} + +.password-validation-requirements .list-group-item { + display: none; +} + +.password-validation-contains-non-alphanumeric-character .tooltip { + font-family: 'Courier New', Courier; + font-size: 1.1em; + letter-spacing: 5px; +} + .logs { color: #555; font-family: 'Courier New', Courier; @@ -142,7 +157,11 @@ input + .form, .form + button, .form-control + .btn-block, -.form-control + .form-control { +.form-control + .form-control, +.form-control + .input-group, +.form-control + .list-group, +.input-group + .btn-block, +.list-group + .btn-block { margin-top: 5px; } diff --git a/packages/auth/demo/src/index.js b/packages/auth/demo/src/index.js index 05a391fe868..c0ac8110135 100644 --- a/packages/auth/demo/src/index.js +++ b/packages/auth/demo/src/index.js @@ -72,7 +72,8 @@ import { getRedirectResult, browserPopupRedirectResolver, connectAuthEmulator, - initializeRecaptchaConfig + initializeRecaptchaConfig, + validatePassword } from '@firebase/auth'; import { config } from './config'; @@ -518,6 +519,152 @@ function onInitializeRecaptchaConfig() { initializeRecaptchaConfig(auth); } +/** + * Updates the displayed validation status for the inputted password. + * @param {string} sectionIdPrefix The ID prefix of the section to show the password requirements in. + */ +function onValidatePassword(sectionIdPrefix) { + /** + * Updates the displayed status for a requirement. + * @param {string} id The ID of the DOM element displaying the requirement status. + * @param {boolean | undefined} status Whether the requirement is met. + */ + function setRequirementStatus(id, status) { + // Hide the requirement if the status does not include it. + if (status === undefined) { + $(id).hide(); + return; + } + + if (status) { + $(id).removeClass('list-group-item-danger'); + $(id).addClass('list-group-item-success'); + } else { + $(id).removeClass('list-group-item-success'); + $(id).addClass('list-group-item-danger'); + } + $(id).show(); + } + + const idPrefix = sectionIdPrefix + 'password-validation-'; + const requirementsId = idPrefix + 'requirements'; + const passwordId = sectionIdPrefix + 'password'; + + const password = $(passwordId).val(); + validatePassword(auth, password).then( + status => { + const passwordPolicy = status.passwordPolicy; + const customStrengthOptions = passwordPolicy.customStrengthOptions; + + // Only show options required by the password policy. + $(requirementsId).children().hide(); + + // Do not show requirements on sign-in if the policy is not enforced for existing passwords. + if ( + sectionIdPrefix === '#signin-' && + !passwordPolicy.forceUpgradeOnSignin + ) { + return; + } + + // Display a message if the password policy is not being enforced. + const notEnforcedId = idPrefix + 'not-enforced'; + if (passwordPolicy.enforcementState === 'OFF') { + $(notEnforcedId).show(); + } else { + $(notEnforcedId).hide(); + } + + if (customStrengthOptions.minPasswordLength) { + $(idPrefix + 'min-length').text( + customStrengthOptions.minPasswordLength + ); + } + if (customStrengthOptions.maxPasswordLength) { + $(idPrefix + 'max-length').text( + customStrengthOptions.maxPasswordLength + ); + } + if (customStrengthOptions.containsNonAlphanumericCharacter) { + $(idPrefix + 'allowed-non-alphanumeric-characters').attr( + 'data-original-title', + passwordPolicy.allowedNonAlphanumericCharacters + ); + } + Object.keys(status).forEach(requirement => { + if (requirement !== 'passwordPolicy') { + // Get the requirement ID by converting to kebab case. + const requirementId = + idPrefix + + requirement.replace(/[A-Z]/g, match => '-' + match.toLowerCase()); + setRequirementStatus(requirementId, status[requirement]); + } + }); + + // Show a note that existing password must meet the policy if trying to sign-in. + if (sectionIdPrefix === '#signin-') { + const forceUpgradeId = idPrefix + 'force-upgrade'; + if (passwordPolicy.forceUpgradeOnSignin) { + $(forceUpgradeId).show(); + } else { + $(forceUpgradeId).hide(); + } + } + + $(passwordId).prop('disabled', false); + $(requirementsId).show(); + + // Fix the border radius, since hidden elements are still considered in styling. + const borderRadius = '5px'; + const requirements = $( + idPrefix + 'requirements .list-group-item:visible' + ); + requirements.each((index, elem) => { + if (index === 0) { + $(elem).css('border-top-left-radius', borderRadius); + $(elem).css('border-top-right-radius', borderRadius); + } + if (index === requirements.length - 1) { + $(elem).css('border-bottom-left-radius', borderRadius); + $(elem).css('border-bottom-right-radius', borderRadius); + } + }); + }, + error => { + // Disable the password input and hide the requirements since validation cannot be performed. + if (error.code === `auth/unsupported-password-policy-schema-version`) { + $(passwordId).prop('disabled', true); + } + $(requirementsId).hide(); + onAuthError(error); + } + ); +} + +/** + * Hides requirements in a section when the password field is blurred and empty. + * @param {string} sectionIdPrefix The ID prefix of the section to hide the password requirements in. + */ +function onBlurPassword(sectionIdPrefix) { + if ($(sectionIdPrefix + 'password').val() === '') { + const id = sectionIdPrefix + 'password-validation-requirements'; + $(id).hide(); + } +} + +/** + * Toggles text visibility for the password validation input field. + * @param {string} sectionIdPrefix The ID prefix of the DOM element of the password input. + */ +function onToggleViewPassword(sectionIdPrefix) { + const id = sectionIdPrefix + 'password'; + if ($(id).prop('type') === 'password') { + $(id).prop('type', 'text'); + } else { + $(id).prop('type', 'password'); + } +} + /** * Signs in with a generic IdP credential. */ @@ -2056,6 +2203,23 @@ function initApp() { $('#sign-in-anonymously').click(onSignInAnonymously); $('.set-tenant-id').click(onSetTenantID); $('#initialize-recaptcha-config').click(onInitializeRecaptchaConfig); + + $('#signin-password').keyup(() => onValidatePassword('#signin-')); + $('#signup-password').keyup(() => onValidatePassword('#signup-')); + $('#password-reset-password').keyup(() => + onValidatePassword('#password-reset-') + ); + + $('#signin-view-password').click(() => onToggleViewPassword('#signin-')); + $('#signup-view-password').click(() => onToggleViewPassword('#signup-')); + $('#password-reset-view-password').click(() => + onToggleViewPassword('#password-reset-') + ); + + $('#signin-password').blur(() => onBlurPassword('#signin-')); + $('#signup-password').blur(() => onBlurPassword('#signup-')); + $('#password-reset-password').blur(() => onBlurPassword('#password-reset-')); + $('#sign-in-with-generic-idp-credential').click( onSignInWithGenericIdPCredential ); diff --git a/packages/auth/karma.conf.js b/packages/auth/karma.conf.js index 4f82e4ab5a7..4a124545faf 100644 --- a/packages/auth/karma.conf.js +++ b/packages/auth/karma.conf.js @@ -38,7 +38,10 @@ function getTestFiles(argv) { return ['src/**/*.test.ts', 'test/helpers/**/*.test.ts']; } else if (argv.integration) { if (argv.prodbackend) { - return ['test/integration/flows/totp.test.ts']; + return [ + 'test/integration/flows/totp.test.ts', + 'test/integration/flows/password_policy.test.ts' + ]; } return argv.local ? ['test/integration/flows/*.test.ts'] diff --git a/packages/auth/src/api/errors.ts b/packages/auth/src/api/errors.ts index 58dc180b0be..d2f07ca6e27 100644 --- a/packages/auth/src/api/errors.ts +++ b/packages/auth/src/api/errors.ts @@ -98,7 +98,8 @@ export const enum ServerError { MISSING_CLIENT_TYPE = 'MISSING_CLIENT_TYPE', MISSING_RECAPTCHA_VERSION = 'MISSING_RECAPTCHA_VERSION', INVALID_RECAPTCHA_VERSION = 'INVALID_RECAPTCHA_VERSION', - INVALID_REQ_TYPE = 'INVALID_REQ_TYPE' + INVALID_REQ_TYPE = 'INVALID_REQ_TYPE', + PASSWORD_DOES_NOT_MEET_REQUIREMENTS = 'PASSWORD_DOES_NOT_MEET_REQUIREMENTS' } /** @@ -177,6 +178,8 @@ export const SERVER_ERROR_MAP: Partial> = { // Other errors. [ServerError.TOO_MANY_ATTEMPTS_TRY_LATER]: AuthErrorCode.TOO_MANY_ATTEMPTS_TRY_LATER, + [ServerError.PASSWORD_DOES_NOT_MEET_REQUIREMENTS]: + AuthErrorCode.PASSWORD_DOES_NOT_MEET_REQUIREMENTS, // Phone Auth related errors. [ServerError.INVALID_CODE]: AuthErrorCode.INVALID_CODE, diff --git a/packages/auth/src/api/index.ts b/packages/auth/src/api/index.ts index d3e18b66a6c..4a17173099f 100644 --- a/packages/auth/src/api/index.ts +++ b/packages/auth/src/api/index.ts @@ -67,7 +67,8 @@ export const enum Endpoint { FINALIZE_MFA_SIGN_IN = '/v2/accounts/mfaSignIn:finalize', WITHDRAW_MFA = '/v2/accounts/mfaEnrollment:withdraw', GET_PROJECT_CONFIG = '/v1/projects', - GET_RECAPTCHA_CONFIG = '/v2/recaptchaConfig' + GET_RECAPTCHA_CONFIG = '/v2/recaptchaConfig', + GET_PASSWORD_POLICY = '/v2/passwordPolicy' } export const enum RecaptchaClientType { diff --git a/packages/auth/src/api/password_policy/get_password_policy.test.ts b/packages/auth/src/api/password_policy/get_password_policy.test.ts new file mode 100644 index 00000000000..d85935f6756 --- /dev/null +++ b/packages/auth/src/api/password_policy/get_password_policy.test.ts @@ -0,0 +1,90 @@ +/** + * @license + * Copyright 2023 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, use } from 'chai'; +import chaiAsPromised from 'chai-as-promised'; + +import { Endpoint, HttpHeader } from '../'; +import { mockEndpoint } from '../../../test/helpers/api/helper'; +import { testAuth, TestAuth } from '../../../test/helpers/mock_auth'; +import * as mockFetch from '../../../test/helpers/mock_fetch'; +import { _getPasswordPolicy } from './get_password_policy'; +import { ServerError } from '../errors'; +import { FirebaseError } from '@firebase/util'; + +use(chaiAsPromised); + +describe('api/password_policy/getPasswordPolicy', () => { + const TEST_MIN_PASSWORD_LENGTH = 6; + const TEST_ALLOWED_NON_ALPHANUMERIC_CHARS = ['!']; + const TEST_SCHEMA_VERSION = 1; + + let auth: TestAuth; + + beforeEach(async () => { + auth = await testAuth(); + mockFetch.setUp(); + }); + + afterEach(mockFetch.tearDown); + + it('should GET to the correct endpoint', async () => { + const mock = mockEndpoint(Endpoint.GET_PASSWORD_POLICY, { + customStrengthOptions: { + minPasswordLength: TEST_MIN_PASSWORD_LENGTH + }, + allowedNonAlphanumericCharacters: TEST_ALLOWED_NON_ALPHANUMERIC_CHARS, + schemaVersion: TEST_SCHEMA_VERSION + }); + + const response = await _getPasswordPolicy(auth); + expect(response.customStrengthOptions.minPasswordLength).to.eql( + TEST_MIN_PASSWORD_LENGTH + ); + expect(response.allowedNonAlphanumericCharacters).to.eql( + TEST_ALLOWED_NON_ALPHANUMERIC_CHARS + ); + expect(response.schemaVersion).to.eql(TEST_SCHEMA_VERSION); + expect(mock.calls[0].method).to.eq('GET'); + expect(mock.calls[0].headers!.get(HttpHeader.X_CLIENT_VERSION)).to.eq( + 'testSDK/0.0.0' + ); + }); + + it('should handle errors', async () => { + mockEndpoint( + Endpoint.GET_PASSWORD_POLICY, + { + error: { + code: 400, + message: ServerError.TOO_MANY_ATTEMPTS_TRY_LATER, + errors: [ + { + message: ServerError.TOO_MANY_ATTEMPTS_TRY_LATER + } + ] + } + }, + 400 + ); + + await expect(_getPasswordPolicy(auth)).to.be.rejectedWith( + FirebaseError, + 'Firebase: We have blocked all requests from this device due to unusual activity. Try again later. (auth/too-many-requests).' + ); + }); +}); diff --git a/packages/auth/src/api/password_policy/get_password_policy.ts b/packages/auth/src/api/password_policy/get_password_policy.ts new file mode 100644 index 00000000000..bdf72863789 --- /dev/null +++ b/packages/auth/src/api/password_policy/get_password_policy.ts @@ -0,0 +1,71 @@ +/** + * @license + * Copyright 2023 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 { + _performApiRequest, + Endpoint, + HttpMethod, + _addTidIfNecessary +} from '../index'; +import { Auth } from '../../model/public_types'; + +/** + * Request object for fetching the password policy. + */ +export interface GetPasswordPolicyRequest { + tenantId?: string; +} + +/** + * Response object for fetching the password policy. + */ +export interface GetPasswordPolicyResponse { + customStrengthOptions: { + minPasswordLength?: number; + maxPasswordLength?: number; + containsLowercaseCharacter?: boolean; + containsUppercaseCharacter?: boolean; + containsNumericCharacter?: boolean; + containsNonAlphanumericCharacter?: boolean; + }; + allowedNonAlphanumericCharacters?: string[]; + enforcementState: string; + forceUpgradeOnSignin?: boolean; + schemaVersion: number; +} + +/** + * Fetches the password policy for the currently set tenant or the project if no tenant is set. + * + * @param auth Auth object. + * @param request Password policy request. + * @returns Password policy response. + */ +export async function _getPasswordPolicy( + auth: Auth, + request: GetPasswordPolicyRequest = {} +): Promise { + return _performApiRequest< + GetPasswordPolicyRequest, + GetPasswordPolicyResponse + >( + auth, + HttpMethod.GET, + Endpoint.GET_PASSWORD_POLICY, + _addTidIfNecessary(auth, request) + ); +} diff --git a/packages/auth/src/core/auth/auth_impl.test.ts b/packages/auth/src/core/auth/auth_impl.test.ts index cca01efa54e..baa9abff1d7 100644 --- a/packages/auth/src/core/auth/auth_impl.test.ts +++ b/packages/auth/src/core/auth/auth_impl.test.ts @@ -46,6 +46,8 @@ import { mockEndpointWithParams } from '../../../test/helpers/api/helper'; import { Endpoint, RecaptchaClientType, RecaptchaVersion } from '../../api'; import * as mockFetch from '../../../test/helpers/mock_fetch'; import { AuthErrorCode } from '../errors'; +import { PasswordValidationStatus } from '../../model/public_types'; +import { PasswordPolicyImpl } from './password_policy_impl'; use(sinonChai); use(chaiAsPromised); @@ -788,6 +790,256 @@ describe('core/auth/auth_impl', () => { }); }); + context('passwordPolicy', () => { + const TEST_ALLOWED_NON_ALPHANUMERIC_CHARS = ['!', '(', ')']; + const TEST_ALLOWED_NON_ALPHANUMERIC_STRING = + TEST_ALLOWED_NON_ALPHANUMERIC_CHARS.join(''); + const TEST_MIN_PASSWORD_LENGTH = 6; + const TEST_ENFORCEMENT_STATE_ENFORCE = 'ENFORCE'; + const TEST_FORCE_UPGRADE_ON_SIGN_IN = false; + const TEST_SCHEMA_VERSION = 1; + const TEST_UNSUPPORTED_SCHEMA_VERSION = 0; + const TEST_TENANT_ID = 'tenant-id'; + const TEST_TENANT_ID_UNSUPPORTED_POLICY_VERSION = + 'tenant-id-unsupported-policy-version'; + + const PASSWORD_POLICY_RESPONSE = { + customStrengthOptions: { + minPasswordLength: TEST_MIN_PASSWORD_LENGTH + }, + allowedNonAlphanumericCharacters: TEST_ALLOWED_NON_ALPHANUMERIC_CHARS, + enforcementState: TEST_ENFORCEMENT_STATE_ENFORCE, + schemaVersion: TEST_SCHEMA_VERSION + }; + const PASSWORD_POLICY_RESPONSE_REQUIRE_NUMERIC = { + customStrengthOptions: { + minPasswordLength: TEST_MIN_PASSWORD_LENGTH, + containsNumericCharacter: true + }, + allowedNonAlphanumericCharacters: TEST_ALLOWED_NON_ALPHANUMERIC_CHARS, + enforcementState: TEST_ENFORCEMENT_STATE_ENFORCE, + schemaVersion: TEST_SCHEMA_VERSION + }; + const PASSWORD_POLICY_RESPONSE_UNSUPPORTED_SCHEMA_VERSION = { + customStrengthOptions: { + minPasswordLength: TEST_MIN_PASSWORD_LENGTH, + unsupportedPasswordPolicyProperty: 10 + }, + allowedNonAlphanumericCharacters: TEST_ALLOWED_NON_ALPHANUMERIC_CHARS, + enforcementState: TEST_ENFORCEMENT_STATE_ENFORCE, + forceUpgradeOnSignin: TEST_FORCE_UPGRADE_ON_SIGN_IN, + schemaVersion: TEST_UNSUPPORTED_SCHEMA_VERSION + }; + const CACHED_PASSWORD_POLICY = { + customStrengthOptions: { + minPasswordLength: TEST_MIN_PASSWORD_LENGTH + }, + allowedNonAlphanumericCharacters: TEST_ALLOWED_NON_ALPHANUMERIC_STRING, + enforcementState: TEST_ENFORCEMENT_STATE_ENFORCE, + forceUpgradeOnSignin: TEST_FORCE_UPGRADE_ON_SIGN_IN, + schemaVersion: TEST_SCHEMA_VERSION + }; + const CACHED_PASSWORD_POLICY_REQUIRE_NUMERIC = { + customStrengthOptions: { + minPasswordLength: TEST_MIN_PASSWORD_LENGTH, + containsNumericCharacter: true + }, + allowedNonAlphanumericCharacters: TEST_ALLOWED_NON_ALPHANUMERIC_STRING, + enforcementState: TEST_ENFORCEMENT_STATE_ENFORCE, + forceUpgradeOnSignin: TEST_FORCE_UPGRADE_ON_SIGN_IN, + schemaVersion: TEST_SCHEMA_VERSION + }; + const CACHED_PASSWORD_POLICY_UNSUPPORTED_SCHEMA_VERSION = { + customStrengthOptions: { + minPasswordLength: TEST_MIN_PASSWORD_LENGTH + }, + allowedNonAlphanumericCharacters: TEST_ALLOWED_NON_ALPHANUMERIC_STRING, + enforcementState: TEST_ENFORCEMENT_STATE_ENFORCE, + forceUpgradeOnSignin: TEST_FORCE_UPGRADE_ON_SIGN_IN, + schemaVersion: TEST_UNSUPPORTED_SCHEMA_VERSION + }; + + beforeEach(async () => { + mockFetch.setUp(); + mockEndpointWithParams( + Endpoint.GET_PASSWORD_POLICY, + {}, + PASSWORD_POLICY_RESPONSE + ); + mockEndpointWithParams( + Endpoint.GET_PASSWORD_POLICY, + { + tenantId: TEST_TENANT_ID + }, + PASSWORD_POLICY_RESPONSE_REQUIRE_NUMERIC + ); + mockEndpointWithParams( + Endpoint.GET_PASSWORD_POLICY, + { + tenantId: TEST_TENANT_ID_UNSUPPORTED_POLICY_VERSION + }, + PASSWORD_POLICY_RESPONSE_UNSUPPORTED_SCHEMA_VERSION + ); + }); + + afterEach(() => { + mockFetch.tearDown(); + }); + + it('password policy should be set for project if tenant ID is null', async () => { + auth = await testAuth(); + auth.tenantId = null; + await auth._updatePasswordPolicy(); + + expect(auth._getPasswordPolicyInternal()).to.eql(CACHED_PASSWORD_POLICY); + }); + + it('password policy should be set for tenant if tenant ID is not null', async () => { + auth = await testAuth(); + auth.tenantId = TEST_TENANT_ID; + await auth._updatePasswordPolicy(); + + expect(auth._getPasswordPolicyInternal()).to.eql( + CACHED_PASSWORD_POLICY_REQUIRE_NUMERIC + ); + }); + + it('password policy should dynamically switch if tenant ID switches.', async () => { + auth = await testAuth(); + auth.tenantId = null; + await auth._updatePasswordPolicy(); + + auth.tenantId = TEST_TENANT_ID; + await auth._updatePasswordPolicy(); + + auth.tenantId = null; + expect(auth._getPasswordPolicyInternal()).to.eql(CACHED_PASSWORD_POLICY); + auth.tenantId = TEST_TENANT_ID; + expect(auth._getPasswordPolicyInternal()).to.eql( + CACHED_PASSWORD_POLICY_REQUIRE_NUMERIC + ); + auth.tenantId = 'other-tenant-id'; + expect(auth._getPasswordPolicyInternal()).to.be.undefined; + }); + + it('password policy should still be set when the schema version is not supported', async () => { + auth = await testAuth(); + auth.tenantId = TEST_TENANT_ID_UNSUPPORTED_POLICY_VERSION; + await expect(auth._updatePasswordPolicy()).to.be.fulfilled; + + expect(auth._getPasswordPolicyInternal()).to.eql( + CACHED_PASSWORD_POLICY_UNSUPPORTED_SCHEMA_VERSION + ); + }); + + context('#validatePassword', () => { + const PASSWORD_POLICY_IMPL = new PasswordPolicyImpl( + PASSWORD_POLICY_RESPONSE + ); + const PASSWORD_POLICY_IMPL_REQUIRE_NUMERIC = new PasswordPolicyImpl( + PASSWORD_POLICY_RESPONSE_REQUIRE_NUMERIC + ); + const TEST_BASIC_PASSWORD = 'password'; + + it('password meeting the policy for the project should be considered valid', async () => { + const expectedValidationStatus: PasswordValidationStatus = { + isValid: true, + meetsMinPasswordLength: true, + passwordPolicy: PASSWORD_POLICY_IMPL + }; + + auth = await testAuth(); + const status = await auth.validatePassword(TEST_BASIC_PASSWORD); + expect(status).to.eql(expectedValidationStatus); + }); + + it('password not meeting the policy for the project should be considered invalid', async () => { + const expectedValidationStatus: PasswordValidationStatus = { + isValid: false, + meetsMinPasswordLength: false, + passwordPolicy: PASSWORD_POLICY_IMPL + }; + + auth = await testAuth(); + const status = await auth.validatePassword('pass'); + expect(status).to.eql(expectedValidationStatus); + }); + + it('password meeting the policy for the tenant should be considered valid', async () => { + const expectedValidationStatus: PasswordValidationStatus = { + isValid: true, + meetsMinPasswordLength: true, + containsNumericCharacter: true, + passwordPolicy: PASSWORD_POLICY_IMPL_REQUIRE_NUMERIC + }; + + auth = await testAuth(); + auth.tenantId = TEST_TENANT_ID; + const status = await auth.validatePassword('passw0rd'); + expect(status).to.eql(expectedValidationStatus); + }); + + it('password not meeting the policy for the tenant should be considered invalid', async () => { + const expectedValidationStatus: PasswordValidationStatus = { + isValid: false, + meetsMinPasswordLength: false, + containsNumericCharacter: false, + passwordPolicy: PASSWORD_POLICY_IMPL_REQUIRE_NUMERIC + }; + + auth = await testAuth(); + auth.tenantId = TEST_TENANT_ID; + const status = await auth.validatePassword('pass'); + expect(status).to.eql(expectedValidationStatus); + }); + + it('should use the password policy associated with the tenant ID when the tenant ID switches', async () => { + let expectedValidationStatus: PasswordValidationStatus = { + isValid: true, + meetsMinPasswordLength: true, + passwordPolicy: PASSWORD_POLICY_IMPL + }; + + auth = await testAuth(); + + let status = await auth.validatePassword(TEST_BASIC_PASSWORD); + expect(status).to.eql(expectedValidationStatus); + + expectedValidationStatus = { + isValid: false, + meetsMinPasswordLength: true, + containsNumericCharacter: false, + passwordPolicy: PASSWORD_POLICY_IMPL_REQUIRE_NUMERIC + }; + + auth.tenantId = TEST_TENANT_ID; + status = await auth.validatePassword(TEST_BASIC_PASSWORD); + expect(status).to.eql(expectedValidationStatus); + }); + + it('should throw an error when a password policy with an unsupported schema version is received', async () => { + auth = await testAuth(); + auth.tenantId = TEST_TENANT_ID_UNSUPPORTED_POLICY_VERSION; + await expect( + auth.validatePassword(TEST_BASIC_PASSWORD) + ).to.be.rejectedWith( + AuthErrorCode.UNSUPPORTED_PASSWORD_POLICY_SCHEMA_VERSION + ); + }); + + it('should throw an error when a password policy with an unsupported schema version is already cached', async () => { + auth = await testAuth(); + auth.tenantId = TEST_TENANT_ID_UNSUPPORTED_POLICY_VERSION; + await auth._updatePasswordPolicy(); + await expect( + auth.validatePassword(TEST_BASIC_PASSWORD) + ).to.be.rejectedWith( + AuthErrorCode.UNSUPPORTED_PASSWORD_POLICY_SCHEMA_VERSION + ); + }); + }); + }); + describe('AuthStateReady', () => { let user: UserInternal; let authStateChangedSpy: sinon.SinonSpy; diff --git a/packages/auth/src/core/auth/auth_impl.ts b/packages/auth/src/core/auth/auth_impl.ts index c991d7e9b50..a402928ca99 100644 --- a/packages/auth/src/core/auth/auth_impl.ts +++ b/packages/auth/src/core/auth/auth_impl.ts @@ -31,7 +31,8 @@ import { CompleteFn, ErrorFn, NextFn, - Unsubscribe + Unsubscribe, + PasswordValidationStatus } from '../../model/public_types'; import { createSubscribe, @@ -65,6 +66,9 @@ import { HttpHeader } from '../../api'; import { AuthMiddlewareQueue } from './middleware'; import { RecaptchaConfig } from '../../platform_browser/recaptcha/recaptcha'; import { _logWarn } from '../util/log'; +import { _getPasswordPolicy } from '../../api/password_policy/get_password_policy'; +import { PasswordPolicyInternal } from '../../model/password_policy'; +import { PasswordPolicyImpl } from './password_policy_impl'; interface AsyncAction { (): Promise; @@ -87,6 +91,7 @@ export class AuthImpl implements AuthInternal, _FirebaseService { private readonly beforeStateQueue = new AuthMiddlewareQueue(this); private redirectUser: UserInternal | null = null; private isProactiveRefreshEnabled = false; + private readonly EXPECTED_PASSWORD_POLICY_SCHEMA_VERSION: number = 1; // Any network calls will set this to true and prevent subsequent emulator // initialization @@ -99,6 +104,8 @@ export class AuthImpl implements AuthInternal, _FirebaseService { _DEFAULT_AUTH_ERROR_FACTORY; _agentRecaptchaConfig: RecaptchaConfig | null = null; _tenantRecaptchaConfigs: Record = {}; + _projectPasswordPolicy: PasswordPolicyInternal | null = null; + _tenantPasswordPolicies: Record = {}; readonly name: string; // Tracks the last notified UID for state change listeners to prevent @@ -401,6 +408,54 @@ export class AuthImpl implements AuthInternal, _FirebaseService { } } + async validatePassword(password: string): Promise { + if (!this._getPasswordPolicyInternal()) { + await this._updatePasswordPolicy(); + } + + // Password policy will be defined after fetching. + const passwordPolicy: PasswordPolicyInternal = + this._getPasswordPolicyInternal()!; + + // Check that the policy schema version is supported by the SDK. + // TODO: Update this logic to use a max supported policy schema version once we have multiple schema versions. + if ( + passwordPolicy.schemaVersion !== + this.EXPECTED_PASSWORD_POLICY_SCHEMA_VERSION + ) { + return Promise.reject( + this._errorFactory.create( + AuthErrorCode.UNSUPPORTED_PASSWORD_POLICY_SCHEMA_VERSION, + {} + ) + ); + } + + return passwordPolicy.validatePassword(password); + } + + _getPasswordPolicyInternal(): PasswordPolicyInternal | null { + if (this.tenantId === null) { + return this._projectPasswordPolicy; + } else { + return this._tenantPasswordPolicies[this.tenantId]; + } + } + + async _updatePasswordPolicy(): Promise { + const response = await _getPasswordPolicy(this); + + const passwordPolicy: PasswordPolicyInternal = new PasswordPolicyImpl( + response + ); + + if (this.tenantId === null) { + this._projectPasswordPolicy = passwordPolicy; + } else { + this._tenantPasswordPolicies[this.tenantId] = passwordPolicy; + } + } + _getPersistence(): string { return this.assertedPersistence.persistence.type; } diff --git a/packages/auth/src/core/auth/password_policy_impl.test.ts b/packages/auth/src/core/auth/password_policy_impl.test.ts new file mode 100644 index 00000000000..2a9801347c2 --- /dev/null +++ b/packages/auth/src/core/auth/password_policy_impl.test.ts @@ -0,0 +1,429 @@ +/** + * @license + * Copyright 2023 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, use } from 'chai'; +import chaiAsPromised from 'chai-as-promised'; +import sinonChai from 'sinon-chai'; +import { + PasswordPolicy, + PasswordValidationStatus +} from '../../model/public_types'; +import { PasswordPolicyImpl } from './password_policy_impl'; +import { GetPasswordPolicyResponse } from '../../api/password_policy/get_password_policy'; +import { PasswordPolicyInternal } from '../../model/password_policy'; + +use(sinonChai); +use(chaiAsPromised); + +describe('core/auth/password_policy_impl', () => { + const TEST_MIN_PASSWORD_LENGTH = 6; + const TEST_MAX_PASSWORD_LENGTH = 12; + const TEST_CONTAINS_LOWERCASE = true; + const TEST_CONTAINS_UPPERCASE = true; + const TEST_CONTAINS_NUMERIC = true; + const TEST_CONTAINS_NON_ALPHANUMERIC = true; + const TEST_ALLOWED_NON_ALPHANUMERIC_CHARS = ['!', '(', ')', '@']; + const TEST_ALLOWED_NON_ALPHANUMERIC_STRING = + TEST_ALLOWED_NON_ALPHANUMERIC_CHARS.join(''); + const TEST_ENFORCEMENT_STATE_ENFORCE = 'ENFORCE'; + const TEST_ENFORCEMENT_STATE_OFF = 'OFF'; + const TEST_REQUIRE_ALL_FORCE_UPGRADE_ON_SIGN_IN = true; + const TEST_REQUIRE_LENGTH_FORCE_UPGRADE_ON_SIGN_IN = false; + const TEST_SCHEMA_VERSION = 1; + const PASSWORD_POLICY_RESPONSE_REQUIRE_ALL: GetPasswordPolicyResponse = { + customStrengthOptions: { + minPasswordLength: TEST_MIN_PASSWORD_LENGTH, + maxPasswordLength: TEST_MAX_PASSWORD_LENGTH, + containsLowercaseCharacter: TEST_CONTAINS_LOWERCASE, + containsUppercaseCharacter: TEST_CONTAINS_UPPERCASE, + containsNumericCharacter: TEST_CONTAINS_NUMERIC, + containsNonAlphanumericCharacter: TEST_CONTAINS_NON_ALPHANUMERIC + }, + allowedNonAlphanumericCharacters: TEST_ALLOWED_NON_ALPHANUMERIC_CHARS, + enforcementState: TEST_ENFORCEMENT_STATE_ENFORCE, + forceUpgradeOnSignin: TEST_REQUIRE_ALL_FORCE_UPGRADE_ON_SIGN_IN, + schemaVersion: TEST_SCHEMA_VERSION + }; + const PASSWORD_POLICY_RESPONSE_REQUIRE_LENGTH: GetPasswordPolicyResponse = { + customStrengthOptions: { + minPasswordLength: TEST_MIN_PASSWORD_LENGTH, + maxPasswordLength: TEST_MAX_PASSWORD_LENGTH + }, + allowedNonAlphanumericCharacters: TEST_ALLOWED_NON_ALPHANUMERIC_CHARS, + enforcementState: TEST_ENFORCEMENT_STATE_OFF, + forceUpgradeOnSignin: TEST_REQUIRE_LENGTH_FORCE_UPGRADE_ON_SIGN_IN, + schemaVersion: TEST_SCHEMA_VERSION + }; + const PASSWORD_POLICY_RESPONSE_REQUIRE_NUMERIC: GetPasswordPolicyResponse = { + customStrengthOptions: { + minPasswordLength: TEST_MIN_PASSWORD_LENGTH, + maxPasswordLength: TEST_MAX_PASSWORD_LENGTH, + containsNumericCharacter: TEST_CONTAINS_NUMERIC + }, + allowedNonAlphanumericCharacters: TEST_ALLOWED_NON_ALPHANUMERIC_CHARS, + enforcementState: TEST_ENFORCEMENT_STATE_ENFORCE, + schemaVersion: TEST_SCHEMA_VERSION + }; + const PASSWORD_POLICY_RESPONSE_UNSPECIFIED_ENFORCEMENT_STATE: GetPasswordPolicyResponse = + { + customStrengthOptions: { + minPasswordLength: TEST_MIN_PASSWORD_LENGTH, + maxPasswordLength: TEST_MAX_PASSWORD_LENGTH + }, + allowedNonAlphanumericCharacters: TEST_ALLOWED_NON_ALPHANUMERIC_CHARS, + enforcementState: 'ENFORCEMENT_STATE_UNSPECIFIED', + schemaVersion: TEST_SCHEMA_VERSION + }; + const PASSWORD_POLICY_RESPONSE_NO_NON_ALPHANUMERIC_CHARS: GetPasswordPolicyResponse = + { + customStrengthOptions: { + minPasswordLength: TEST_MIN_PASSWORD_LENGTH, + maxPasswordLength: TEST_MAX_PASSWORD_LENGTH + }, + enforcementState: TEST_ENFORCEMENT_STATE_ENFORCE, + schemaVersion: TEST_SCHEMA_VERSION + }; + const PASSWORD_POLICY_RESPONSE_NO_MIN_LENGTH: GetPasswordPolicyResponse = { + customStrengthOptions: {}, + enforcementState: TEST_ENFORCEMENT_STATE_ENFORCE, + schemaVersion: TEST_SCHEMA_VERSION + }; + const PASSWORD_POLICY_REQUIRE_ALL: PasswordPolicy = { + customStrengthOptions: { + minPasswordLength: TEST_MIN_PASSWORD_LENGTH, + maxPasswordLength: TEST_MAX_PASSWORD_LENGTH, + containsLowercaseLetter: TEST_CONTAINS_LOWERCASE, + containsUppercaseLetter: TEST_CONTAINS_UPPERCASE, + containsNumericCharacter: TEST_CONTAINS_NUMERIC, + containsNonAlphanumericCharacter: TEST_CONTAINS_UPPERCASE + }, + allowedNonAlphanumericCharacters: TEST_ALLOWED_NON_ALPHANUMERIC_STRING, + enforcementState: TEST_ENFORCEMENT_STATE_ENFORCE, + forceUpgradeOnSignin: TEST_REQUIRE_ALL_FORCE_UPGRADE_ON_SIGN_IN + }; + const PASSWORD_POLICY_REQUIRE_LENGTH: PasswordPolicy = { + customStrengthOptions: { + minPasswordLength: TEST_MIN_PASSWORD_LENGTH, + maxPasswordLength: TEST_MAX_PASSWORD_LENGTH + }, + allowedNonAlphanumericCharacters: TEST_ALLOWED_NON_ALPHANUMERIC_STRING, + enforcementState: TEST_ENFORCEMENT_STATE_OFF, + forceUpgradeOnSignin: TEST_REQUIRE_LENGTH_FORCE_UPGRADE_ON_SIGN_IN + }; + const TEST_EMPTY_PASSWORD = ''; + + context('#PasswordPolicyImpl', () => { + it('can construct the password policy from the backend response', () => { + const policy: PasswordPolicyInternal = new PasswordPolicyImpl( + PASSWORD_POLICY_RESPONSE_REQUIRE_ALL + ); + expect(policy.customStrengthOptions).to.eql( + PASSWORD_POLICY_REQUIRE_ALL.customStrengthOptions + ); + expect(policy.allowedNonAlphanumericCharacters).to.eql( + PASSWORD_POLICY_REQUIRE_ALL.allowedNonAlphanumericCharacters + ); + expect(policy.enforcementState).to.eql( + PASSWORD_POLICY_REQUIRE_ALL.enforcementState + ); + expect(policy.schemaVersion).to.eql( + PASSWORD_POLICY_RESPONSE_REQUIRE_ALL.schemaVersion + ); + }); + + it('only includes requirements defined in the response', () => { + const policy: PasswordPolicyInternal = new PasswordPolicyImpl( + PASSWORD_POLICY_RESPONSE_REQUIRE_LENGTH + ); + expect(policy.customStrengthOptions).to.eql( + PASSWORD_POLICY_REQUIRE_LENGTH.customStrengthOptions + ); + expect(policy.allowedNonAlphanumericCharacters).to.eql( + PASSWORD_POLICY_REQUIRE_LENGTH.allowedNonAlphanumericCharacters + ); + expect(policy.enforcementState).to.eql( + PASSWORD_POLICY_REQUIRE_LENGTH.enforcementState + ); + expect(policy.schemaVersion).to.eql( + PASSWORD_POLICY_RESPONSE_REQUIRE_LENGTH.schemaVersion + ); + // Requirements that are not in the response should be undefined. + expect(policy.customStrengthOptions.containsLowercaseLetter).to.be + .undefined; + expect(policy.customStrengthOptions.containsUppercaseLetter).to.be + .undefined; + expect(policy.customStrengthOptions.containsNumericCharacter).to.be + .undefined; + expect(policy.customStrengthOptions.containsNonAlphanumericCharacter).to + .be.undefined; + }); + + it("assigns 'OFF' as the enforcement state when it is unspecified", () => { + const policy: PasswordPolicyInternal = new PasswordPolicyImpl( + PASSWORD_POLICY_RESPONSE_UNSPECIFIED_ENFORCEMENT_STATE + ); + expect(policy.enforcementState).to.eql(TEST_ENFORCEMENT_STATE_OFF); + }); + + it('assigns false to forceUpgradeOnSignin when it is undefined in the response', () => { + const policy: PasswordPolicyInternal = new PasswordPolicyImpl( + PASSWORD_POLICY_RESPONSE_REQUIRE_NUMERIC + ); + expect(policy.forceUpgradeOnSignin).to.be.false; + }); + + it('assigns an empty string as the allowed non-alphanumeric characters when they are undefined in the response', () => { + const policy: PasswordPolicyInternal = new PasswordPolicyImpl( + PASSWORD_POLICY_RESPONSE_NO_NON_ALPHANUMERIC_CHARS + ); + expect(policy.allowedNonAlphanumericCharacters).to.eql(''); + }); + + it('assigns a default minimum length if it is undefined in the response', () => { + const policy: PasswordPolicyInternal = new PasswordPolicyImpl( + PASSWORD_POLICY_RESPONSE_NO_MIN_LENGTH + ); + expect(policy.customStrengthOptions.minPasswordLength).to.eql(6); + }); + + context('#validatePassword', () => { + const PASSWORD_POLICY_IMPL_REQUIRE_ALL = new PasswordPolicyImpl( + PASSWORD_POLICY_RESPONSE_REQUIRE_ALL + ); + const PASSWORD_POLICY_IMPL_REQUIRE_LENGTH = new PasswordPolicyImpl( + PASSWORD_POLICY_RESPONSE_REQUIRE_LENGTH + ); + const PASSWORD_POLICY_IMPL_REQUIRE_NUMERIC = new PasswordPolicyImpl( + PASSWORD_POLICY_RESPONSE_REQUIRE_NUMERIC + ); + + it('password that is too short is considered invalid', async () => { + const policy = PASSWORD_POLICY_IMPL_REQUIRE_ALL; + const expectedValidationStatus: PasswordValidationStatus = { + isValid: false, + meetsMinPasswordLength: false, + meetsMaxPasswordLength: true, + containsLowercaseLetter: true, + containsUppercaseLetter: true, + containsNumericCharacter: true, + containsNonAlphanumericCharacter: true, + passwordPolicy: policy + }; + + const status = policy.validatePassword('P4ss!'); + expect(status).to.eql(expectedValidationStatus); + }); + + it('password that is too long is considered invalid', async () => { + const policy = PASSWORD_POLICY_IMPL_REQUIRE_ALL; + const expectedValidationStatus: PasswordValidationStatus = { + isValid: false, + meetsMinPasswordLength: true, + meetsMaxPasswordLength: false, + containsLowercaseLetter: true, + containsUppercaseLetter: true, + containsNumericCharacter: true, + containsNonAlphanumericCharacter: true, + passwordPolicy: policy + }; + + const status = policy.validatePassword('Password01234!'); + expect(status).to.eql(expectedValidationStatus); + }); + + it('password that does not contain a lowercase character is considered invalid', async () => { + const policy = PASSWORD_POLICY_IMPL_REQUIRE_ALL; + const expectedValidationStatus: PasswordValidationStatus = { + isValid: false, + meetsMinPasswordLength: true, + meetsMaxPasswordLength: true, + containsLowercaseLetter: false, + containsUppercaseLetter: true, + containsNumericCharacter: true, + containsNonAlphanumericCharacter: true, + passwordPolicy: policy + }; + + const status = policy.validatePassword('P4SSWORD!'); + expect(status).to.eql(expectedValidationStatus); + }); + + it('password that does not contain an uppercase character is considered invalid', async () => { + const policy = PASSWORD_POLICY_IMPL_REQUIRE_ALL; + const expectedValidationStatus: PasswordValidationStatus = { + isValid: false, + meetsMinPasswordLength: true, + meetsMaxPasswordLength: true, + containsLowercaseLetter: true, + containsUppercaseLetter: false, + containsNumericCharacter: true, + containsNonAlphanumericCharacter: true, + passwordPolicy: policy + }; + + const status = policy.validatePassword('p4ssword!'); + expect(status).to.eql(expectedValidationStatus); + }); + + it('password that does not contain a numeric character is considered invalid', async () => { + const policy = PASSWORD_POLICY_IMPL_REQUIRE_ALL; + const expectedValidationStatus: PasswordValidationStatus = { + isValid: false, + meetsMinPasswordLength: true, + meetsMaxPasswordLength: true, + containsLowercaseLetter: true, + containsUppercaseLetter: true, + containsNumericCharacter: false, + containsNonAlphanumericCharacter: true, + passwordPolicy: policy + }; + + const status = policy.validatePassword('Password!'); + expect(status).to.eql(expectedValidationStatus); + }); + + it('password that does not contain a non-alphanumeric character is considered invalid', async () => { + const policy = PASSWORD_POLICY_IMPL_REQUIRE_ALL; + const expectedValidationStatus: PasswordValidationStatus = { + isValid: false, + meetsMinPasswordLength: true, + meetsMaxPasswordLength: true, + containsLowercaseLetter: true, + containsUppercaseLetter: true, + containsNumericCharacter: true, + containsNonAlphanumericCharacter: false, + passwordPolicy: policy + }; + + let status = policy.validatePassword('P4ssword'); + expect(status).to.eql(expectedValidationStatus); + + // Characters not in allowedNonAlphanumericCharacters should not be considered valid. + status = policy.validatePassword('P4sswo*d'); + expect(status).to.eql(expectedValidationStatus); + }); + + it('passwords that only partially meet requirements are considered invalid', async () => { + const policy = PASSWORD_POLICY_IMPL_REQUIRE_ALL; + let expectedValidationStatus: PasswordValidationStatus = { + isValid: false, + meetsMinPasswordLength: true, + meetsMaxPasswordLength: false, + containsLowercaseLetter: true, + containsUppercaseLetter: false, + containsNumericCharacter: true, + containsNonAlphanumericCharacter: false, + passwordPolicy: policy + }; + + let status = policy.validatePassword('password01234'); + expect(status).to.eql(expectedValidationStatus); + + expectedValidationStatus = { + isValid: false, + meetsMinPasswordLength: false, + meetsMaxPasswordLength: true, + containsLowercaseLetter: false, + containsUppercaseLetter: true, + containsNumericCharacter: false, + containsNonAlphanumericCharacter: true, + passwordPolicy: policy + }; + + status = policy.validatePassword('P@SS'); + expect(status).to.eql(expectedValidationStatus); + }); + + it('should only include statuses for requirements included in the policy', async () => { + const policy = PASSWORD_POLICY_IMPL_REQUIRE_LENGTH; + let expectedValidationStatus: PasswordValidationStatus = { + isValid: true, + meetsMinPasswordLength: true, + meetsMaxPasswordLength: true, + passwordPolicy: policy + }; + + let status = policy.validatePassword('password'); + expect(status).to.eql(expectedValidationStatus); + expect(status.containsLowercaseLetter).to.be.undefined; + expect(status.containsUppercaseLetter).to.be.undefined; + expect(status.containsNumericCharacter).to.be.undefined; + expect(status.containsNonAlphanumericCharacter).to.be.undefined; + + expectedValidationStatus = { + isValid: false, + meetsMinPasswordLength: false, + meetsMaxPasswordLength: true, + passwordPolicy: PASSWORD_POLICY_IMPL_REQUIRE_LENGTH + }; + + status = policy.validatePassword('pass'); + expect(status).to.eql(expectedValidationStatus); + expect(status.containsLowercaseLetter).to.be.undefined; + expect(status.containsUppercaseLetter).to.be.undefined; + expect(status.containsNumericCharacter).to.be.undefined; + expect(status.containsNonAlphanumericCharacter).to.be.undefined; + }); + + it('should include statuses for requirements included in the policy when the password is an empty string', async () => { + let policy = PASSWORD_POLICY_IMPL_REQUIRE_ALL; + let expectedValidationStatus: PasswordValidationStatus = { + isValid: false, + meetsMinPasswordLength: false, + meetsMaxPasswordLength: true, + containsLowercaseLetter: false, + containsUppercaseLetter: false, + containsNumericCharacter: false, + containsNonAlphanumericCharacter: false, + passwordPolicy: policy + }; + + let status = policy.validatePassword(TEST_EMPTY_PASSWORD); + expect(status).to.eql(expectedValidationStatus); + + policy = PASSWORD_POLICY_IMPL_REQUIRE_NUMERIC; + expectedValidationStatus = { + isValid: false, + meetsMinPasswordLength: false, + meetsMaxPasswordLength: true, + containsNumericCharacter: false, + passwordPolicy: policy + }; + + status = policy.validatePassword(TEST_EMPTY_PASSWORD); + expect(status).to.eql(expectedValidationStatus); + expect(status.containsLowercaseLetter).to.be.undefined; + expect(status.containsUppercaseLetter).to.be.undefined; + expect(status.containsNonAlphanumericCharacter).to.be.undefined; + }); + + it("should consider a password invalid if it does not meet all requirements even if the enforcement state is 'OFF'", async () => { + const policy = PASSWORD_POLICY_IMPL_REQUIRE_NUMERIC; + const expectedValidationStatus: PasswordValidationStatus = { + isValid: false, + meetsMinPasswordLength: false, + meetsMaxPasswordLength: true, + containsNumericCharacter: true, + passwordPolicy: policy + }; + + const status = policy.validatePassword('p4ss'); + expect(status).to.eql(expectedValidationStatus); + }); + }); + }); +}); diff --git a/packages/auth/src/core/auth/password_policy_impl.ts b/packages/auth/src/core/auth/password_policy_impl.ts new file mode 100644 index 00000000000..1f76a6026a7 --- /dev/null +++ b/packages/auth/src/core/auth/password_policy_impl.ts @@ -0,0 +1,192 @@ +/** + * @license + * Copyright 2023 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 { GetPasswordPolicyResponse } from '../../api/password_policy/get_password_policy'; +import { + PasswordPolicyCustomStrengthOptions, + PasswordPolicyInternal, + PasswordValidationStatusInternal +} from '../../model/password_policy'; +import { PasswordValidationStatus } from '../../model/public_types'; + +// Minimum min password length enforced by the backend, even if no minimum length is set. +const MINIMUM_MIN_PASSWORD_LENGTH = 6; + +/** + * Stores password policy requirements and provides password validation against the policy. + * + * @internal + */ +export class PasswordPolicyImpl implements PasswordPolicyInternal { + readonly customStrengthOptions: PasswordPolicyCustomStrengthOptions; + readonly allowedNonAlphanumericCharacters: string; + readonly enforcementState: string; + readonly forceUpgradeOnSignin: boolean; + readonly schemaVersion: number; + + constructor(response: GetPasswordPolicyResponse) { + // Only include custom strength options defined in the response. + const responseOptions = response.customStrengthOptions; + this.customStrengthOptions = {}; + // TODO: Remove once the backend is updated to include the minimum min password length instead of undefined when there is no minimum length set. + this.customStrengthOptions.minPasswordLength = + responseOptions.minPasswordLength ?? MINIMUM_MIN_PASSWORD_LENGTH; + if (responseOptions.maxPasswordLength) { + this.customStrengthOptions.maxPasswordLength = + responseOptions.maxPasswordLength; + } + if (responseOptions.containsLowercaseCharacter !== undefined) { + this.customStrengthOptions.containsLowercaseLetter = + responseOptions.containsLowercaseCharacter; + } + if (responseOptions.containsUppercaseCharacter !== undefined) { + this.customStrengthOptions.containsUppercaseLetter = + responseOptions.containsUppercaseCharacter; + } + if (responseOptions.containsNumericCharacter !== undefined) { + this.customStrengthOptions.containsNumericCharacter = + responseOptions.containsNumericCharacter; + } + if (responseOptions.containsNonAlphanumericCharacter !== undefined) { + this.customStrengthOptions.containsNonAlphanumericCharacter = + responseOptions.containsNonAlphanumericCharacter; + } + + this.enforcementState = response.enforcementState; + if (this.enforcementState === 'ENFORCEMENT_STATE_UNSPECIFIED') { + this.enforcementState = 'OFF'; + } + + // Use an empty string if no non-alphanumeric characters are specified in the response. + this.allowedNonAlphanumericCharacters = + response.allowedNonAlphanumericCharacters?.join('') ?? ''; + + this.forceUpgradeOnSignin = response.forceUpgradeOnSignin ?? false; + this.schemaVersion = response.schemaVersion; + } + + validatePassword(password: string): PasswordValidationStatus { + const status: PasswordValidationStatusInternal = { + isValid: true, + passwordPolicy: this + }; + + // Check the password length and character options. + this.validatePasswordLengthOptions(password, status); + this.validatePasswordCharacterOptions(password, status); + + // Combine the status into single isValid property. + status.isValid &&= status.meetsMinPasswordLength ?? true; + status.isValid &&= status.meetsMaxPasswordLength ?? true; + status.isValid &&= status.containsLowercaseLetter ?? true; + status.isValid &&= status.containsUppercaseLetter ?? true; + status.isValid &&= status.containsNumericCharacter ?? true; + status.isValid &&= status.containsNonAlphanumericCharacter ?? true; + + return status; + } + + /** + * Validates that the password meets the length options for the policy. + * + * @param password Password to validate. + * @param status Validation status. + */ + private validatePasswordLengthOptions( + password: string, + status: PasswordValidationStatusInternal + ): void { + const minPasswordLength = this.customStrengthOptions.minPasswordLength; + const maxPasswordLength = this.customStrengthOptions.maxPasswordLength; + if (minPasswordLength) { + status.meetsMinPasswordLength = password.length >= minPasswordLength; + } + if (maxPasswordLength) { + status.meetsMaxPasswordLength = password.length <= maxPasswordLength; + } + } + + /** + * Validates that the password meets the character options for the policy. + * + * @param password Password to validate. + * @param status Validation status. + */ + private validatePasswordCharacterOptions( + password: string, + status: PasswordValidationStatusInternal + ): void { + // Assign statuses for requirements even if the password is an empty string. + this.updatePasswordCharacterOptionsStatuses( + status, + /* containsLowercaseCharacter= */ false, + /* containsUppercaseCharacter= */ false, + /* containsNumericCharacter= */ false, + /* containsNonAlphanumericCharacter= */ false + ); + + let passwordChar; + for (let i = 0; i < password.length; i++) { + passwordChar = password.charAt(i); + this.updatePasswordCharacterOptionsStatuses( + status, + /* containsLowercaseCharacter= */ passwordChar >= 'a' && + passwordChar <= 'z', + /* containsUppercaseCharacter= */ passwordChar >= 'A' && + passwordChar <= 'Z', + /* containsNumericCharacter= */ passwordChar >= '0' && + passwordChar <= '9', + /* containsNonAlphanumericCharacter= */ this.allowedNonAlphanumericCharacters.includes( + passwordChar + ) + ); + } + } + + /** + * Updates the running validation status with the statuses for the character options. + * Expected to be called each time a character is processed to update each option status + * based on the current character. + * + * @param status Validation status. + * @param containsLowercaseCharacter Whether the character is a lowercase letter. + * @param containsUppercaseCharacter Whether the character is an uppercase letter. + * @param containsNumericCharacter Whether the character is a numeric character. + * @param containsNonAlphanumericCharacter Whether the character is a non-alphanumeric character. + */ + private updatePasswordCharacterOptionsStatuses( + status: PasswordValidationStatusInternal, + containsLowercaseCharacter: boolean, + containsUppercaseCharacter: boolean, + containsNumericCharacter: boolean, + containsNonAlphanumericCharacter: boolean + ): void { + if (this.customStrengthOptions.containsLowercaseLetter) { + status.containsLowercaseLetter ||= containsLowercaseCharacter; + } + if (this.customStrengthOptions.containsUppercaseLetter) { + status.containsUppercaseLetter ||= containsUppercaseCharacter; + } + if (this.customStrengthOptions.containsNumericCharacter) { + status.containsNumericCharacter ||= containsNumericCharacter; + } + if (this.customStrengthOptions.containsNonAlphanumericCharacter) { + status.containsNonAlphanumericCharacter ||= + containsNonAlphanumericCharacter; + } + } +} diff --git a/packages/auth/src/core/errors.ts b/packages/auth/src/core/errors.ts index e450c31deaf..586a33d3d34 100644 --- a/packages/auth/src/core/errors.ts +++ b/packages/auth/src/core/errors.ts @@ -132,7 +132,9 @@ export const enum AuthErrorCode { MISSING_CLIENT_TYPE = 'missing-client-type', MISSING_RECAPTCHA_VERSION = 'missing-recaptcha-version', INVALID_RECAPTCHA_VERSION = 'invalid-recaptcha-version', - INVALID_REQ_TYPE = 'invalid-req-type' + INVALID_REQ_TYPE = 'invalid-req-type', + UNSUPPORTED_PASSWORD_POLICY_SCHEMA_VERSION = 'unsupported-password-policy-schema-version', + PASSWORD_DOES_NOT_MEET_REQUIREMENTS = 'password-does-not-meet-requirements' } function _debugErrorMap(): ErrorMap { @@ -381,7 +383,11 @@ function _debugErrorMap(): ErrorMap { 'The reCAPTCHA version is missing when sending request to the backend.', [AuthErrorCode.INVALID_REQ_TYPE]: 'Invalid request parameters.', [AuthErrorCode.INVALID_RECAPTCHA_VERSION]: - 'The reCAPTCHA version is invalid when sending request to the backend.' + 'The reCAPTCHA version is invalid when sending request to the backend.', + [AuthErrorCode.UNSUPPORTED_PASSWORD_POLICY_SCHEMA_VERSION]: + 'The password policy received from the backend uses a schema version that is not supported by this version of the Firebase SDK.', + [AuthErrorCode.PASSWORD_DOES_NOT_MEET_REQUIREMENTS]: + 'The password does not meet the requirements.' }; } diff --git a/packages/auth/src/core/index.ts b/packages/auth/src/core/index.ts index 6295a75ab85..4718ba818a9 100644 --- a/packages/auth/src/core/index.ts +++ b/packages/auth/src/core/index.ts @@ -23,9 +23,11 @@ import { User, CompleteFn, ErrorFn, - Unsubscribe + Unsubscribe, + PasswordValidationStatus } from '../model/public_types'; import { _initializeRecaptchaConfig } from '../platform_browser/recaptcha/recaptcha_enterprise_verifier'; +import { _castAuth } from '../core/auth/auth_impl'; export { debugErrorMap, @@ -95,6 +97,37 @@ export function initializeRecaptchaConfig(auth: Auth): Promise { return _initializeRecaptchaConfig(auth); } +/** + * Validates the password against the password policy configured for the project or tenant. + * + * @remarks + * If no tenant ID is set on the `Auth` instance, then this method will use the password + * policy configured for the project. Otherwise, this method will use the policy configured + * for the tenant. If a password policy has not been configured, then the default policy + * configured for all projects will be used. + * + * If an auth flow fails because a submitted password does not meet the password policy + * requirements and this method has previously been called, then this method will use the + * most recent policy available when called again. + * + * @example + * ```javascript + * validatePassword(auth, 'some-password'); + * ``` + * + * @param auth The {@link Auth} instance. + * @param password The password to validate. + * + * @public + */ +export async function validatePassword( + auth: Auth, + password: string +): Promise { + const authInternal = _castAuth(auth); + return authInternal.validatePassword(password); +} + /** * Adds an observer for changes to the signed-in user's ID token. * diff --git a/packages/auth/src/core/strategies/email_and_password.test.ts b/packages/auth/src/core/strategies/email_and_password.test.ts index ed43e943b44..95fe8c8c06c 100644 --- a/packages/auth/src/core/strategies/email_and_password.test.ts +++ b/packages/auth/src/core/strategies/email_and_password.test.ts @@ -55,8 +55,20 @@ import { _initializeRecaptchaConfig } from '../../platform_browser/recaptcha/rec use(chaiAsPromised); use(sinonChai); +const TEST_ID_TOKEN = 'id-token'; +const TEST_REFRESH_TOKEN = 'refresh-token'; +const TEST_TOKEN_EXPIRY_TIME = '1234'; + +const TEST_LOCAL_ID = 'local-id'; +const TEST_SERVER_USER: APIUserInfo = { + localId: TEST_LOCAL_ID +}; + +const TEST_EMAIL = 'foo@bar.com'; +const TEST_PASSWORD = 'some-password'; + describe('core/strategies/sendPasswordResetEmail', () => { - const email = 'foo@bar.com'; + const email = TEST_EMAIL; let auth: TestAuth; @@ -324,7 +336,7 @@ describe('core/strategies/confirmPasswordReset', () => { it('should confirm the password reset and not return the email', async () => { const mock = mockEndpoint(Endpoint.RESET_PASSWORD, { - email: 'foo@bar.com' + email: TEST_EMAIL }); const response = await confirmPasswordReset(auth, oobCode, newPassword); expect(response).to.be.undefined; @@ -396,7 +408,7 @@ describe('core/strategies/applyActionCode', () => { describe('core/strategies/checkActionCode', () => { const oobCode = 'oob-code'; - const email = 'foo@bar.com'; + const email = TEST_EMAIL; const newEmail = 'new@email.com'; let auth: TestAuth; @@ -411,7 +423,7 @@ describe('core/strategies/checkActionCode', () => { it('should verify the oob code', async () => { const mock = mockEndpoint(Endpoint.RESET_PASSWORD, { requestType: ActionCodeOperation.PASSWORD_RESET, - email: 'foo@bar.com' + email: TEST_EMAIL }); const response = await checkActionCode(auth, oobCode); expect(response).to.eql({ @@ -479,7 +491,7 @@ describe('core/strategies/checkActionCode', () => { describe('core/strategies/verifyPasswordResetCode', () => { const oobCode = 'oob-code'; - const email = 'foo@bar.com'; + const email = TEST_EMAIL; let auth: TestAuth; @@ -493,7 +505,7 @@ describe('core/strategies/verifyPasswordResetCode', () => { it('should verify the oob code', async () => { const mock = mockEndpoint(Endpoint.RESET_PASSWORD, { requestType: ActionCodeOperation.PASSWORD_RESET, - email: 'foo@bar.com', + email: TEST_EMAIL, previousEmail: null }); const response = await verifyPasswordResetCode(auth, oobCode); @@ -535,17 +547,15 @@ describe('core/strategies/verifyPasswordResetCode', () => { describe('core/strategies/email_and_password/createUserWithEmailAndPassword', () => { let auth: TestAuth; - const serverUser: APIUserInfo = { - localId: 'local-id' - }; + const serverUser: APIUserInfo = TEST_SERVER_USER; beforeEach(async () => { auth = await testAuth(); mockFetch.setUp(); mockEndpoint(Endpoint.SIGN_UP, { - idToken: 'id-token', - refreshToken: 'refresh-token', - expiresIn: '1234', + idToken: TEST_ID_TOKEN, + refreshToken: TEST_REFRESH_TOKEN, + expiresIn: TEST_TOKEN_EXPIRY_TIME, localId: serverUser.localId! }); mockEndpoint(Endpoint.GET_ACCOUNT_INFO, { @@ -558,13 +568,13 @@ describe('core/strategies/email_and_password/createUserWithEmailAndPassword', () const { _tokenResponse, user, operationType } = (await createUserWithEmailAndPassword( auth, - 'some-email', - 'some-password' + TEST_EMAIL, + TEST_PASSWORD )) as UserCredentialInternal; expect(_tokenResponse).to.eql({ - idToken: 'id-token', - refreshToken: 'refresh-token', - expiresIn: '1234', + idToken: TEST_ID_TOKEN, + refreshToken: TEST_REFRESH_TOKEN, + expiresIn: TEST_TOKEN_EXPIRY_TIME, localId: serverUser.localId! }); expect(operationType).to.eq(OperationType.SIGN_IN); @@ -623,13 +633,13 @@ describe('core/strategies/email_and_password/createUserWithEmailAndPassword', () const { _tokenResponse, user, operationType } = (await createUserWithEmailAndPassword( auth, - 'some-email', - 'some-password' + TEST_EMAIL, + TEST_PASSWORD )) as UserCredentialInternal; expect(_tokenResponse).to.eql({ - idToken: 'id-token', - refreshToken: 'refresh-token', - expiresIn: '1234', + idToken: TEST_ID_TOKEN, + refreshToken: TEST_REFRESH_TOKEN, + expiresIn: TEST_TOKEN_EXPIRY_TIME, localId: serverUser.localId! }); expect(operationType).to.eq(OperationType.SIGN_IN); @@ -654,13 +664,13 @@ describe('core/strategies/email_and_password/createUserWithEmailAndPassword', () const { _tokenResponse, user, operationType } = (await createUserWithEmailAndPassword( auth, - 'some-email', - 'some-password' + TEST_EMAIL, + TEST_PASSWORD )) as UserCredentialInternal; expect(_tokenResponse).to.eql({ - idToken: 'id-token', - refreshToken: 'refresh-token', - expiresIn: '1234', + idToken: TEST_ID_TOKEN, + refreshToken: TEST_REFRESH_TOKEN, + expiresIn: TEST_TOKEN_EXPIRY_TIME, localId: serverUser.localId! }); expect(operationType).to.eq(OperationType.SIGN_IN); @@ -677,8 +687,8 @@ describe('core/strategies/email_and_password/createUserWithEmailAndPassword', () mockEndpointWithParams( Endpoint.SIGN_UP, { - email: 'some-email', - password: 'some-password', + email: TEST_EMAIL, + password: TEST_PASSWORD, clientType: RecaptchaClientType.WEB }, { @@ -694,16 +704,16 @@ describe('core/strategies/email_and_password/createUserWithEmailAndPassword', () mockEndpointWithParams( Endpoint.SIGN_UP, { - email: 'some-email', - password: 'some-password', + email: TEST_EMAIL, + password: TEST_PASSWORD, captchaResp: 'recaptcha-response', clientType: RecaptchaClientType.WEB, recaptchaVersion: RecaptchaVersion.ENTERPRISE }, { - idToken: 'id-token', - refreshToken: 'refresh-token', - expiresIn: '1234', + idToken: TEST_ID_TOKEN, + refreshToken: TEST_REFRESH_TOKEN, + expiresIn: TEST_TOKEN_EXPIRY_TIME, localId: serverUser.localId! } ); @@ -732,13 +742,13 @@ describe('core/strategies/email_and_password/createUserWithEmailAndPassword', () const { _tokenResponse, user, operationType } = (await createUserWithEmailAndPassword( auth, - 'some-email', - 'some-password' + TEST_EMAIL, + TEST_PASSWORD )) as UserCredentialInternal; expect(_tokenResponse).to.eql({ - idToken: 'id-token', - refreshToken: 'refresh-token', - expiresIn: '1234', + idToken: TEST_ID_TOKEN, + refreshToken: TEST_REFRESH_TOKEN, + expiresIn: TEST_TOKEN_EXPIRY_TIME, localId: serverUser.localId! }); expect(operationType).to.eq(OperationType.SIGN_IN); @@ -750,17 +760,15 @@ describe('core/strategies/email_and_password/createUserWithEmailAndPassword', () describe('core/strategies/email_and_password/signInWithEmailAndPassword', () => { let auth: TestAuth; - const serverUser: APIUserInfo = { - localId: 'local-id' - }; + const serverUser: APIUserInfo = TEST_SERVER_USER; beforeEach(async () => { auth = await testAuth(); mockFetch.setUp(); mockEndpoint(Endpoint.SIGN_IN_WITH_PASSWORD, { - idToken: 'id-token', - refreshToken: 'refresh-token', - expiresIn: '1234', + idToken: TEST_ID_TOKEN, + refreshToken: TEST_REFRESH_TOKEN, + expiresIn: TEST_TOKEN_EXPIRY_TIME, localId: serverUser.localId! }); mockEndpoint(Endpoint.GET_ACCOUNT_INFO, { @@ -773,13 +781,13 @@ describe('core/strategies/email_and_password/signInWithEmailAndPassword', () => const { _tokenResponse, user, operationType } = (await signInWithEmailAndPassword( auth, - 'some-email', - 'some-password' + TEST_EMAIL, + TEST_PASSWORD )) as UserCredentialInternal; expect(_tokenResponse).to.eql({ - idToken: 'id-token', - refreshToken: 'refresh-token', - expiresIn: '1234', + idToken: TEST_ID_TOKEN, + refreshToken: TEST_REFRESH_TOKEN, + expiresIn: TEST_TOKEN_EXPIRY_TIME, localId: serverUser.localId! }); expect(operationType).to.eq(OperationType.SIGN_IN); @@ -787,3 +795,638 @@ describe('core/strategies/email_and_password/signInWithEmailAndPassword', () => expect(user.isAnonymous).to.be.false; }); }); + +describe('password policy cache is updated in auth flows upon error', () => { + let auth: TestAuth; + + const TEST_MIN_PASSWORD_LENGTH = 6; + const TEST_ALLOWED_NON_ALPHANUMERIC_CHARS = ['!', '(', ')']; + const TEST_ALLOWED_NON_ALPHANUMERIC_STRING = + TEST_ALLOWED_NON_ALPHANUMERIC_CHARS.join(''); + const TEST_ENFORCEMENT_STATE = 'ENFORCE'; + const TEST_FORCE_UPGRADE_ON_SIGN_IN = false; + const TEST_SCHEMA_VERSION = 1; + const TEST_TENANT_ID = 'tenant-id'; + const TEST_TENANT_ID_REQUIRE_NUMERIC = 'other-tenant-id'; + const PASSWORD_ERROR_MSG = + 'Firebase: The password does not meet the requirements. (auth/password-does-not-meet-requirements).'; + const PASSWORD_POLICY_RESPONSE = { + customStrengthOptions: { + minPasswordLength: TEST_MIN_PASSWORD_LENGTH + }, + allowedNonAlphanumericCharacters: TEST_ALLOWED_NON_ALPHANUMERIC_CHARS, + enforcementState: TEST_ENFORCEMENT_STATE, + schemaVersion: TEST_SCHEMA_VERSION + }; + const PASSWORD_POLICY_RESPONSE_REQUIRE_NUMERIC = { + customStrengthOptions: { + minPasswordLength: TEST_MIN_PASSWORD_LENGTH, + containsNumericCharacter: true + }, + allowedNonAlphanumericCharacters: TEST_ALLOWED_NON_ALPHANUMERIC_CHARS, + enforcementState: TEST_ENFORCEMENT_STATE, + schemaVersion: TEST_SCHEMA_VERSION + }; + const CACHED_PASSWORD_POLICY = { + customStrengthOptions: { + minPasswordLength: TEST_MIN_PASSWORD_LENGTH + }, + allowedNonAlphanumericCharacters: TEST_ALLOWED_NON_ALPHANUMERIC_STRING, + enforcementState: TEST_ENFORCEMENT_STATE, + forceUpgradeOnSignin: TEST_FORCE_UPGRADE_ON_SIGN_IN, + schemaVersion: TEST_SCHEMA_VERSION + }; + const CACHED_PASSWORD_POLICY_REQUIRE_NUMERIC = { + customStrengthOptions: { + minPasswordLength: TEST_MIN_PASSWORD_LENGTH, + containsNumericCharacter: true + }, + allowedNonAlphanumericCharacters: TEST_ALLOWED_NON_ALPHANUMERIC_STRING, + enforcementState: TEST_ENFORCEMENT_STATE, + forceUpgradeOnSignin: TEST_FORCE_UPGRADE_ON_SIGN_IN, + schemaVersion: TEST_SCHEMA_VERSION + }; + const TEST_RECAPTCHA_RESPONSE = 'recaptcha-response'; + const TEST_SITE_KEY = 'site-key'; + const RECAPTCHA_CONFIG_RESPONSE_ENFORCE = { + recaptchaKey: 'foo/bar/to/' + TEST_SITE_KEY, + recaptchaEnforcementState: [ + { + provider: 'EMAIL_PASSWORD_PROVIDER', + enforcementState: 'ENFORCE' + } + ] + }; + const MISSING_RECAPTCHA_TOKEN_ERROR = { + error: { + code: 400, + message: ServerError.MISSING_RECAPTCHA_TOKEN + } + }; + let policyEndpointMock: mockFetch.Route; + let policyEndpointMockWithTenant: mockFetch.Route; + let policyEndpointMockWithOtherTenant: mockFetch.Route; + + /** + * Wait for 50ms to allow the password policy to be fetched and recached. + */ + async function waitForRecachePasswordPolicy(): Promise { + await new Promise(resolve => { + setTimeout(() => { + resolve(); + }, 50); + }); + } + + /** + * Mock reCAPTCHA JS loading method and manually set window.recaptcha. + */ + async function setUpRecaptcha(): Promise { + // Initialize the reCAPTCHA config so the auth flows use reCAPTCHA. + await _initializeRecaptchaConfig(auth); + + sinon.stub(jsHelpers, '_loadJS').returns(Promise.resolve(new Event(''))); + const recaptcha = new MockGreCAPTCHATopLevel(); + window.grecaptcha = recaptcha; + const stub = sinon.stub(recaptcha.enterprise, 'execute'); + stub + .withArgs(TEST_SITE_KEY, { + action: RecaptchaActionName.SIGN_UP_PASSWORD + }) + .returns(Promise.resolve(TEST_RECAPTCHA_RESPONSE)); + stub + .withArgs(TEST_SITE_KEY, { + action: RecaptchaActionName.SIGN_IN_WITH_PASSWORD + }) + .returns(Promise.resolve(TEST_RECAPTCHA_RESPONSE)); + } + + beforeEach(async () => { + auth = await testAuth(); + mockFetch.setUp(); + policyEndpointMock = mockEndpointWithParams( + Endpoint.GET_PASSWORD_POLICY, + {}, + PASSWORD_POLICY_RESPONSE + ); + policyEndpointMockWithTenant = mockEndpointWithParams( + Endpoint.GET_PASSWORD_POLICY, + { + tenantId: TEST_TENANT_ID + }, + PASSWORD_POLICY_RESPONSE + ); + policyEndpointMockWithOtherTenant = mockEndpointWithParams( + Endpoint.GET_PASSWORD_POLICY, + { + tenantId: TEST_TENANT_ID_REQUIRE_NUMERIC + }, + PASSWORD_POLICY_RESPONSE_REQUIRE_NUMERIC + ); + mockEndpointWithParams( + Endpoint.GET_RECAPTCHA_CONFIG, + { + clientType: RecaptchaClientType.WEB, + version: RecaptchaVersion.ENTERPRISE + }, + RECAPTCHA_CONFIG_RESPONSE_ENFORCE + ); + + // Mock reCAPTCHA with a fake response. + if (typeof window === 'undefined') { + return; + } + const recaptcha = new MockGreCAPTCHATopLevel(); + window.grecaptcha = recaptcha; + sinon + .stub(recaptcha.enterprise, 'execute') + .returns(Promise.resolve(TEST_RECAPTCHA_RESPONSE)); + }); + + afterEach(() => { + mockFetch.tearDown(); + sinon.restore(); + }); + + context('#createUserWithEmailAndPassword', () => { + beforeEach(() => { + mockEndpoint(Endpoint.SIGN_UP, { + idToken: TEST_ID_TOKEN, + refreshToken: TEST_REFRESH_TOKEN, + expiresIn: TEST_TOKEN_EXPIRY_TIME, + localId: TEST_SERVER_USER.localId! + }); + mockEndpoint(Endpoint.GET_ACCOUNT_INFO, { + users: [TEST_SERVER_USER] + }); + }); + + it('does not update the cached password policy upon successful sign up when there is no existing policy cache', async () => { + await expect( + createUserWithEmailAndPassword(auth, TEST_EMAIL, TEST_PASSWORD) + ).to.be.fulfilled; + + // Wait to ensure the password policy is not fetched and recached. + await waitForRecachePasswordPolicy(); + + expect(policyEndpointMock.calls.length).to.eq(0); + expect(auth._getPasswordPolicyInternal()).to.be.null; + }); + + it('does not update the cached password policy upon successful sign up when there is an existing policy cache', async () => { + await auth._updatePasswordPolicy(); + + await expect( + createUserWithEmailAndPassword(auth, TEST_EMAIL, TEST_PASSWORD) + ).to.be.fulfilled; + + // Wait to ensure the password policy is not fetched and recached. + await waitForRecachePasswordPolicy(); + + expect(policyEndpointMock.calls.length).to.eq(1); + expect(auth._getPasswordPolicyInternal()).to.eql(CACHED_PASSWORD_POLICY); + }); + + context('handles password validation errors', () => { + beforeEach(() => { + mockEndpoint( + Endpoint.SIGN_UP, + { + error: { + code: 400, + message: ServerError.PASSWORD_DOES_NOT_MEET_REQUIREMENTS + } + }, + 400 + ); + }); + + it('updates the cached password policy when password does not meet backend requirements for the project', async () => { + await auth._updatePasswordPolicy(); + expect(policyEndpointMock.calls.length).to.eq(1); + expect(auth._getPasswordPolicyInternal()).to.eql( + CACHED_PASSWORD_POLICY + ); + + // Password policy changed after previous fetch. + policyEndpointMock.response = PASSWORD_POLICY_RESPONSE_REQUIRE_NUMERIC; + await expect( + createUserWithEmailAndPassword(auth, TEST_EMAIL, TEST_PASSWORD) + ).to.be.rejectedWith(FirebaseError, PASSWORD_ERROR_MSG); + + // Wait for the password policy to be fetched and recached. + await waitForRecachePasswordPolicy(); + + expect(policyEndpointMock.calls.length).to.eq(2); + expect(auth._getPasswordPolicyInternal()).to.eql( + CACHED_PASSWORD_POLICY_REQUIRE_NUMERIC + ); + }); + + it('updates the cached password policy when password does not meet backend requirements for the tenant', async () => { + auth.tenantId = TEST_TENANT_ID; + await auth._updatePasswordPolicy(); + expect(policyEndpointMockWithTenant.calls.length).to.eq(1); + expect(auth._getPasswordPolicyInternal()).to.eql( + CACHED_PASSWORD_POLICY + ); + + // Password policy changed after previous fetch. + policyEndpointMockWithTenant.response = + PASSWORD_POLICY_RESPONSE_REQUIRE_NUMERIC; + await expect( + createUserWithEmailAndPassword(auth, TEST_EMAIL, TEST_PASSWORD) + ).to.be.rejectedWith(FirebaseError, PASSWORD_ERROR_MSG); + + // Wait for the password policy to be fetched and recached. + await waitForRecachePasswordPolicy(); + + expect(policyEndpointMockWithTenant.calls.length).to.eq(2); + expect(auth._getPasswordPolicyInternal()).to.eql( + CACHED_PASSWORD_POLICY_REQUIRE_NUMERIC + ); + }); + + it('does not update the cached password policy upon error if policy has not previously been fetched', async () => { + expect(auth._getPasswordPolicyInternal()).to.be.null; + + await expect( + createUserWithEmailAndPassword(auth, TEST_EMAIL, TEST_PASSWORD) + ).to.be.rejectedWith(FirebaseError, PASSWORD_ERROR_MSG); + + // Wait for the password policy to be fetched and recached. + await waitForRecachePasswordPolicy(); + + expect(policyEndpointMock.calls.length).to.eq(0); + expect(auth._getPasswordPolicyInternal()).to.be.null; + }); + + it('does not update the cached password policy upon error if tenant changes and policy has not previously been fetched', async () => { + auth.tenantId = TEST_TENANT_ID; + await auth._updatePasswordPolicy(); + expect(policyEndpointMockWithTenant.calls.length).to.eq(1); + expect(auth._getPasswordPolicyInternal()).to.eql( + CACHED_PASSWORD_POLICY + ); + + auth.tenantId = TEST_TENANT_ID_REQUIRE_NUMERIC; + await expect( + createUserWithEmailAndPassword(auth, TEST_EMAIL, TEST_PASSWORD) + ).to.be.rejectedWith(FirebaseError, PASSWORD_ERROR_MSG); + + // Wait to ensure the password policy is not fetched and recached. + await waitForRecachePasswordPolicy(); + + expect(policyEndpointMockWithOtherTenant.calls.length).to.eq(0); + expect(auth._getPasswordPolicyInternal()).to.be.undefined; + }); + + it('updates the cached password policy even when a MISSING_RECAPTCHA_TOKEN error is handled first', async () => { + if (typeof window === 'undefined') { + return; + } + + await auth._updatePasswordPolicy(); + expect(policyEndpointMock.calls.length).to.eq(1); + expect(auth._getPasswordPolicyInternal()).to.eql( + CACHED_PASSWORD_POLICY + ); + + // Password policy changed after previous fetch. + policyEndpointMock.response = PASSWORD_POLICY_RESPONSE_REQUIRE_NUMERIC; + + // First sign up without reCAPTCHA token should fail with MISSING_RECAPTCHA_TOKEN error. + mockEndpointWithParams( + Endpoint.SIGN_UP, + { + email: TEST_EMAIL, + password: TEST_PASSWORD, + clientType: RecaptchaClientType.WEB + }, + MISSING_RECAPTCHA_TOKEN_ERROR, + 400 + ); + + // Set up reCAPTCHA mock and configs. + await setUpRecaptcha(); + + await expect( + createUserWithEmailAndPassword(auth, TEST_EMAIL, TEST_PASSWORD) + ).to.be.rejectedWith(FirebaseError, PASSWORD_ERROR_MSG); + + // Wait for the password policy to be fetched and recached. + await waitForRecachePasswordPolicy(); + + expect(policyEndpointMock.calls.length).to.eq(2); + expect(auth._getPasswordPolicyInternal()).to.eql( + CACHED_PASSWORD_POLICY_REQUIRE_NUMERIC + ); + }); + }); + }); + + context('#confirmPasswordReset', () => { + const TEST_OOB_CODE = 'oob-code'; + + beforeEach(() => { + mockEndpoint(Endpoint.RESET_PASSWORD, { + email: TEST_EMAIL + }); + }); + + it('does not update the cached password policy upon successful password reset when there is no existing policy cache', async () => { + await expect(confirmPasswordReset(auth, TEST_OOB_CODE, TEST_PASSWORD)).to + .be.fulfilled; + + // Wait to ensure the password policy is not fetched and recached. + await waitForRecachePasswordPolicy(); + + expect(policyEndpointMock.calls.length).to.eq(0); + expect(auth._getPasswordPolicyInternal()).to.be.null; + }); + + it('does not update the cached password policy upon successful password reset when there is an existing policy cache', async () => { + await auth._updatePasswordPolicy(); + + await expect(confirmPasswordReset(auth, TEST_OOB_CODE, TEST_PASSWORD)).to + .be.fulfilled; + + // Wait to ensure the password policy is not fetched and recached. + await waitForRecachePasswordPolicy(); + + expect(policyEndpointMock.calls.length).to.eq(1); + expect(auth._getPasswordPolicyInternal()).to.eql(CACHED_PASSWORD_POLICY); + }); + + context('handles password validation errors', () => { + beforeEach(() => { + mockEndpoint( + Endpoint.RESET_PASSWORD, + { + error: { + code: 400, + message: ServerError.PASSWORD_DOES_NOT_MEET_REQUIREMENTS + } + }, + 400 + ); + }); + + it('updates the cached password policy when password does not meet backend requirements for the project', async () => { + await auth._updatePasswordPolicy(); + expect(policyEndpointMock.calls.length).to.eq(1); + expect(auth._getPasswordPolicyInternal()).to.eql( + CACHED_PASSWORD_POLICY + ); + + // Password policy changed after previous fetch. + policyEndpointMock.response = PASSWORD_POLICY_RESPONSE_REQUIRE_NUMERIC; + await expect( + confirmPasswordReset(auth, TEST_OOB_CODE, TEST_PASSWORD) + ).to.be.rejectedWith(FirebaseError, PASSWORD_ERROR_MSG); + + // Wait for the password policy to be fetched and recached. + await waitForRecachePasswordPolicy(); + + expect(policyEndpointMock.calls.length).to.eq(2); + expect(auth._getPasswordPolicyInternal()).to.eql( + CACHED_PASSWORD_POLICY_REQUIRE_NUMERIC + ); + }); + + it('updates the cached password policy when password does not meet backend requirements for the tenant', async () => { + auth.tenantId = TEST_TENANT_ID; + await auth._updatePasswordPolicy(); + expect(policyEndpointMockWithTenant.calls.length).to.eq(1); + expect(auth._getPasswordPolicyInternal()).to.eql( + CACHED_PASSWORD_POLICY + ); + + // Password policy changed after previous fetch. + policyEndpointMockWithTenant.response = + PASSWORD_POLICY_RESPONSE_REQUIRE_NUMERIC; + await expect( + confirmPasswordReset(auth, TEST_OOB_CODE, TEST_PASSWORD) + ).to.be.rejectedWith(FirebaseError, PASSWORD_ERROR_MSG); + + // Wait for the password policy to be fetched and recached. + await waitForRecachePasswordPolicy(); + + expect(policyEndpointMockWithTenant.calls.length).to.eq(2); + expect(auth._getPasswordPolicyInternal()).to.eql( + CACHED_PASSWORD_POLICY_REQUIRE_NUMERIC + ); + }); + + it('does not update the cached password policy upon error if policy has not previously been fetched', async () => { + expect(auth._getPasswordPolicyInternal()).to.be.null; + + await expect( + confirmPasswordReset(auth, TEST_OOB_CODE, TEST_PASSWORD) + ).to.be.rejectedWith(FirebaseError, PASSWORD_ERROR_MSG); + + // Wait to ensure the password policy is not fetched and recached. + await waitForRecachePasswordPolicy(); + + expect(policyEndpointMock.calls.length).to.eq(0); + expect(auth._getPasswordPolicyInternal()).to.be.null; + }); + + it('does not update the cached password policy upon error if tenant changes and policy has not previously been fetched', async () => { + auth.tenantId = TEST_TENANT_ID; + await auth._updatePasswordPolicy(); + expect(policyEndpointMockWithTenant.calls.length).to.eq(1); + expect(auth._getPasswordPolicyInternal()).to.eql( + CACHED_PASSWORD_POLICY + ); + + auth.tenantId = TEST_TENANT_ID_REQUIRE_NUMERIC; + await expect( + confirmPasswordReset(auth, TEST_OOB_CODE, TEST_PASSWORD) + ).to.be.rejectedWith(FirebaseError, PASSWORD_ERROR_MSG); + + // Wait to ensure the password policy is not fetched and recached. + await waitForRecachePasswordPolicy(); + + expect(policyEndpointMockWithOtherTenant.calls.length).to.eq(0); + expect(auth._getPasswordPolicyInternal()).to.be.undefined; + }); + }); + }); + + context('#signInWithEmailAndPassword', () => { + beforeEach(() => { + mockEndpoint(Endpoint.SIGN_IN_WITH_PASSWORD, { + idToken: TEST_ID_TOKEN, + refreshToken: TEST_REFRESH_TOKEN, + expiresIn: TEST_TOKEN_EXPIRY_TIME, + localId: TEST_SERVER_USER.localId! + }); + mockEndpoint(Endpoint.GET_ACCOUNT_INFO, { + users: [TEST_SERVER_USER] + }); + }); + + it('does not update the cached password policy upon successful sign-in when there is no existing policy cache', async () => { + await expect(signInWithEmailAndPassword(auth, TEST_EMAIL, TEST_PASSWORD)) + .to.be.fulfilled; + + // Wait to ensure the password policy is not fetched and recached. + await waitForRecachePasswordPolicy(); + + expect(policyEndpointMock.calls.length).to.eq(0); + expect(auth._getPasswordPolicyInternal()).to.be.null; + }); + + it('does not update the cached password policy upon successful sign-in when there is an existing policy cache', async () => { + await auth._updatePasswordPolicy(); + + await expect(signInWithEmailAndPassword(auth, TEST_EMAIL, TEST_PASSWORD)) + .to.be.fulfilled; + + // Wait to ensure the password policy is not fetched and recached. + await waitForRecachePasswordPolicy(); + + expect(policyEndpointMock.calls.length).to.eq(1); + expect(auth._getPasswordPolicyInternal()).to.eql(CACHED_PASSWORD_POLICY); + }); + + context('handles password validation errors', () => { + beforeEach(() => { + mockEndpoint( + Endpoint.SIGN_IN_WITH_PASSWORD, + { + error: { + code: 400, + message: ServerError.PASSWORD_DOES_NOT_MEET_REQUIREMENTS + } + }, + 400 + ); + }); + + it('updates the cached password policy when password does not meet backend requirements for the project', async () => { + await auth._updatePasswordPolicy(); + expect(policyEndpointMock.calls.length).to.eq(1); + expect(auth._getPasswordPolicyInternal()).to.eql( + CACHED_PASSWORD_POLICY + ); + + // Password policy changed after previous fetch. + policyEndpointMock.response = PASSWORD_POLICY_RESPONSE_REQUIRE_NUMERIC; + await expect( + signInWithEmailAndPassword(auth, TEST_EMAIL, TEST_PASSWORD) + ).to.be.rejectedWith(FirebaseError, PASSWORD_ERROR_MSG); + + // Wait for the password policy to be fetched and recached. + await waitForRecachePasswordPolicy(); + + expect(policyEndpointMock.calls.length).to.eq(2); + expect(auth._getPasswordPolicyInternal()).to.eql( + CACHED_PASSWORD_POLICY_REQUIRE_NUMERIC + ); + }); + + it('updates the cached password policy when password does not meet backend requirements for the tenant', async () => { + auth.tenantId = TEST_TENANT_ID; + await auth._updatePasswordPolicy(); + expect(policyEndpointMockWithTenant.calls.length).to.eq(1); + expect(auth._getPasswordPolicyInternal()).to.eql( + CACHED_PASSWORD_POLICY + ); + + // Password policy changed after previous fetch. + policyEndpointMockWithTenant.response = + PASSWORD_POLICY_RESPONSE_REQUIRE_NUMERIC; + await expect( + signInWithEmailAndPassword(auth, TEST_EMAIL, TEST_PASSWORD) + ).to.be.rejectedWith(FirebaseError, PASSWORD_ERROR_MSG); + + // Wait for the password policy to be fetched and recached. + await waitForRecachePasswordPolicy(); + + expect(policyEndpointMockWithTenant.calls.length).to.eq(2); + expect(auth._getPasswordPolicyInternal()).to.eql( + CACHED_PASSWORD_POLICY_REQUIRE_NUMERIC + ); + }); + + it('does not update the cached password policy upon error if policy has not previously been fetched', async () => { + expect(auth._getPasswordPolicyInternal()).to.be.null; + + await expect( + signInWithEmailAndPassword(auth, TEST_EMAIL, TEST_PASSWORD) + ).to.be.rejectedWith(FirebaseError, PASSWORD_ERROR_MSG); + + // Wait to ensure the password policy is not fetched and recached. + await waitForRecachePasswordPolicy(); + + expect(policyEndpointMock.calls.length).to.eq(0); + expect(auth._getPasswordPolicyInternal()).to.be.null; + }); + + it('does not update the cached password policy upon error if tenant changes and policy has not previously been fetched', async () => { + auth.tenantId = TEST_TENANT_ID; + await auth._updatePasswordPolicy(); + expect(policyEndpointMockWithTenant.calls.length).to.eq(1); + expect(auth._getPasswordPolicyInternal()).to.eql( + CACHED_PASSWORD_POLICY + ); + + auth.tenantId = TEST_TENANT_ID_REQUIRE_NUMERIC; + await expect( + signInWithEmailAndPassword(auth, TEST_EMAIL, TEST_PASSWORD) + ).to.be.rejectedWith(FirebaseError, PASSWORD_ERROR_MSG); + + // Wait to ensure the password policy is not fetched and recached. + await waitForRecachePasswordPolicy(); + + expect(policyEndpointMockWithOtherTenant.calls.length).to.eq(0); + expect(auth._getPasswordPolicyInternal()).to.be.undefined; + }); + + it('updates the cached password policy even when a MISSING_RECAPTCHA_TOKEN error is handled first', async () => { + if (typeof window === 'undefined') { + return; + } + + await auth._updatePasswordPolicy(); + expect(policyEndpointMock.calls.length).to.eq(1); + expect(auth._getPasswordPolicyInternal()).to.eql( + CACHED_PASSWORD_POLICY + ); + + // Password policy changed after previous fetch. + policyEndpointMock.response = PASSWORD_POLICY_RESPONSE_REQUIRE_NUMERIC; + + // First sign-in without reCAPTCHA token should fail with MISSING_RECAPTCHA_TOKEN error. + mockEndpointWithParams( + Endpoint.SIGN_IN_WITH_PASSWORD, + { + email: TEST_EMAIL, + password: TEST_PASSWORD, + returnSecureToken: true, + clientType: RecaptchaClientType.WEB + }, + MISSING_RECAPTCHA_TOKEN_ERROR, + 400 + ); + + // Set up reCAPTCHA mock and configs. + await setUpRecaptcha(); + + await expect( + signInWithEmailAndPassword(auth, TEST_EMAIL, TEST_PASSWORD) + ).to.be.rejectedWith(FirebaseError, PASSWORD_ERROR_MSG); + + // Wait to ensure the password policy is fetched and recached. + await waitForRecachePasswordPolicy(); + + expect(policyEndpointMock.calls.length).to.eq(2); + expect(auth._getPasswordPolicyInternal()).to.eql( + CACHED_PASSWORD_POLICY_REQUIRE_NUMERIC + ); + }); + }); + }); +}); diff --git a/packages/auth/src/core/strategies/email_and_password.ts b/packages/auth/src/core/strategies/email_and_password.ts index 6ff248f8532..33a2ef8af8d 100644 --- a/packages/auth/src/core/strategies/email_and_password.ts +++ b/packages/auth/src/core/strategies/email_and_password.ts @@ -40,6 +40,26 @@ import { injectRecaptchaFields } from '../../platform_browser/recaptcha/recaptch import { IdTokenResponse } from '../../model/id_token'; import { RecaptchaActionName, RecaptchaClientType } from '../../api'; +/** + * Updates the password policy cached in the {@link Auth} instance if a policy is already + * cached for the project or tenant. + * + * @remarks + * We only fetch the password policy if the password did not meet policy requirements and + * there is an existing policy cached. A developer must call validatePassword at least + * once for the cache to be automatically updated. + * + * @param auth - The {@link Auth} instance. + * + * @private + */ +async function recachePasswordPolicy(auth: Auth): Promise { + const authInternal = _castAuth(auth); + if (authInternal._getPasswordPolicyInternal()) { + await authInternal._updatePasswordPolicy(); + } +} + /** * Sends a password reset email to the given email address. * @@ -154,10 +174,21 @@ export async function confirmPasswordReset( oobCode: string, newPassword: string ): Promise { - await account.resetPassword(getModularInstance(auth), { - oobCode, - newPassword - }); + await account + .resetPassword(getModularInstance(auth), { + oobCode, + newPassword + }) + .catch(async error => { + if ( + error.code === + `auth/${AuthErrorCode.PASSWORD_DOES_NOT_MEET_REQUIREMENTS}` + ) { + void recachePasswordPolicy(auth); + } + + throw error; + }); // Do not return the email. } @@ -307,14 +338,20 @@ export async function createUserWithEmailAndPassword( RecaptchaActionName.SIGN_UP_PASSWORD ); return signUp(authInternal, requestWithRecaptcha); - } else { - return Promise.reject(error); } + + throw error; }); } const response = await signUpResponse.catch(error => { - return Promise.reject(error); + if ( + error.code === `auth/${AuthErrorCode.PASSWORD_DOES_NOT_MEET_REQUIREMENTS}` + ) { + void recachePasswordPolicy(auth); + } + + throw error; }); const userCredential = await UserCredentialImpl._fromIdTokenResponse( @@ -351,5 +388,13 @@ export function signInWithEmailAndPassword( return signInWithCredential( getModularInstance(auth), EmailAuthProvider.credential(email, password) - ); + ).catch(async error => { + if ( + error.code === `auth/${AuthErrorCode.PASSWORD_DOES_NOT_MEET_REQUIREMENTS}` + ) { + void recachePasswordPolicy(auth); + } + + throw error; + }); } diff --git a/packages/auth/src/model/auth.ts b/packages/auth/src/model/auth.ts index 4c82539dc97..818867dd302 100644 --- a/packages/auth/src/model/auth.ts +++ b/packages/auth/src/model/auth.ts @@ -20,6 +20,8 @@ import { AuthSettings, Config, EmulatorConfig, + PasswordPolicy, + PasswordValidationStatus, PopupRedirectResolver, User } from './public_types'; @@ -30,6 +32,7 @@ import { PopupRedirectResolverInternal } from './popup_redirect'; import { UserInternal } from './user'; import { ClientPlatform } from '../core/util/version'; import { RecaptchaConfig } from '../platform_browser/recaptcha/recaptcha'; +import { PasswordPolicyInternal } from './password_policy'; export type AppName = string; export type ApiKey = string; @@ -63,6 +66,8 @@ export interface AuthInternal extends Auth { emulatorConfig: EmulatorConfig | null; _agentRecaptchaConfig: RecaptchaConfig | null; _tenantRecaptchaConfigs: Record; + _projectPasswordPolicy: PasswordPolicy | null; + _tenantPasswordPolicies: Record; _canInitEmulator: boolean; _isInitialized: boolean; _initializationPromise: Promise | null; @@ -83,6 +88,8 @@ export interface AuthInternal extends Auth { _stopProactiveRefresh(): void; _getPersistence(): string; _getRecaptchaConfig(): RecaptchaConfig | null; + _getPasswordPolicyInternal(): PasswordPolicyInternal | null; + _updatePasswordPolicy(): Promise; _logFramework(framework: string): void; _getFrameworks(): readonly string[]; _getAdditionalHeaders(): Promise>; @@ -97,4 +104,5 @@ export interface AuthInternal extends Auth { useDeviceLanguage(): void; signOut(): Promise; + validatePassword(password: string): Promise; } diff --git a/packages/auth/src/model/password_policy.ts b/packages/auth/src/model/password_policy.ts new file mode 100644 index 00000000000..dcad4ec2492 --- /dev/null +++ b/packages/auth/src/model/password_policy.ts @@ -0,0 +1,116 @@ +/** + * @license + * Copyright 2023 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 { PasswordPolicy, PasswordValidationStatus } from './public_types'; + +/** + * Internal typing of password policy that includes the schema version and methods for + * validating that a password meets the policy. The developer does not need access to + * these properties and methods, so they are excluded from the public typing. + * + * @internal + */ +export interface PasswordPolicyInternal extends PasswordPolicy { + /** + * Requirements enforced by the password policy. + */ + readonly customStrengthOptions: PasswordPolicyCustomStrengthOptions; + /** + * Schema version of the password policy. + */ + readonly schemaVersion: number; + /** + * Validates the password against the policy. + * @param password Password to validate. + */ + validatePassword(password: string): PasswordValidationStatus; +} + +/** + * Internal typing of the password policy custom strength options that is modifiable. This + * allows us to construct the strength options before storing them in the policy. + * + * @internal + */ +export interface PasswordPolicyCustomStrengthOptions { + /** + * Minimum password length. + */ + minPasswordLength?: number; + /** + * Maximum password length. + */ + maxPasswordLength?: number; + /** + * Whether the password should contain a lowercase letter. + */ + containsLowercaseLetter?: boolean; + /** + * Whether the password should contain an uppercase letter. + */ + containsUppercaseLetter?: boolean; + /** + * Whether the password should contain a numeric character. + */ + containsNumericCharacter?: boolean; + /** + * Whether the password should contain a non-alphanumeric character. + */ + containsNonAlphanumericCharacter?: boolean; +} + +/** + * Internal typing of password validation status that is modifiable. This allows us to + * construct the validation status before returning it. + * + * @internal + */ +export interface PasswordValidationStatusInternal + extends PasswordValidationStatus { + /** + * Whether the password meets all requirements. + */ + isValid: boolean; + /** + * Whether the password meets the minimum password length. + */ + meetsMinPasswordLength?: boolean; + /** + * Whether the password meets the maximum password length. + */ + meetsMaxPasswordLength?: boolean; + /** + * Whether the password contains a lowercase letter, if required. + */ + containsLowercaseLetter?: boolean; + /** + * Whether the password contains an uppercase letter, if required. + */ + containsUppercaseLetter?: boolean; + /** + * Whether the password contains a numeric character, if required. + */ + containsNumericCharacter?: boolean; + /** + * Whether the password contains a non-alphanumeric character, if required. + */ + containsNonAlphanumericCharacter?: boolean; + /** + * The policy used to validate the password. + */ + passwordPolicy: PasswordPolicy; +} diff --git a/packages/auth/src/model/public_types.ts b/packages/auth/src/model/public_types.ts index bd730ab8dc1..0390ba5e30d 100644 --- a/packages/auth/src/model/public_types.ts +++ b/packages/auth/src/model/public_types.ts @@ -1258,3 +1258,93 @@ export interface Dependencies { */ export interface TotpMultiFactorAssertion extends MultiFactorAssertion {} + +/** + * A structure specifying password policy requirements. + * + * @public + */ +export interface PasswordPolicy { + /** + * Requirements enforced by this password policy. + */ + readonly customStrengthOptions: { + /** + * Minimum password length, or undefined if not configured. + */ + readonly minPasswordLength?: number; + /** + * Maximum password length, or undefined if not configured. + */ + readonly maxPasswordLength?: number; + /** + * Whether the password should contain a lowercase letter, or undefined if not configured. + */ + readonly containsLowercaseLetter?: boolean; + /** + * Whether the password should contain an uppercase letter, or undefined if not configured. + */ + readonly containsUppercaseLetter?: boolean; + /** + * Whether the password should contain a numeric character, or undefined if not configured. + */ + readonly containsNumericCharacter?: boolean; + /** + * Whether the password should contain a non-alphanumeric character, or undefined if not configured. + */ + readonly containsNonAlphanumericCharacter?: boolean; + }; + /** + * List of characters that are considered non-alphanumeric during validation. + */ + readonly allowedNonAlphanumericCharacters: string; + /** + * The enforcement state of the policy. Can be 'OFF' or 'ENFORCE'. + */ + readonly enforcementState: string; + /** + * Whether existing passwords must meet the policy. + */ + readonly forceUpgradeOnSignin: boolean; +} + +/** + * A structure indicating which password policy requirements were met or violated and what the + * requirements are. + * + * @public + */ +export interface PasswordValidationStatus { + /** + * Whether the password meets all requirements. + */ + readonly isValid: boolean; + /** + * Whether the password meets the minimum password length, or undefined if not required. + */ + readonly meetsMinPasswordLength?: boolean; + /** + * Whether the password meets the maximum password length, or undefined if not required. + */ + readonly meetsMaxPasswordLength?: boolean; + /** + * Whether the password contains a lowercase letter, or undefined if not required. + */ + readonly containsLowercaseLetter?: boolean; + /** + * Whether the password contains an uppercase letter, or undefined if not required. + */ + readonly containsUppercaseLetter?: boolean; + /** + * Whether the password contains a numeric character, or undefined if not required. + */ + readonly containsNumericCharacter?: boolean; + /** + * Whether the password contains a non-alphanumeric character, or undefined if not required. + */ + readonly containsNonAlphanumericCharacter?: boolean; + /** + * The policy used to validate the password. + */ + readonly passwordPolicy: PasswordPolicy; +} diff --git a/packages/auth/test/helpers/integration/helpers.ts b/packages/auth/test/helpers/integration/helpers.ts index c0faf4220b6..0ac5b3b4428 100644 --- a/packages/auth/test/helpers/integration/helpers.ts +++ b/packages/auth/test/helpers/integration/helpers.ts @@ -25,6 +25,7 @@ import { getAppConfig, getEmulatorUrl } from './settings'; import { resetEmulator } from './emulator_rest_helpers'; // @ts-ignore - ignore types since this is only used in tests. import totp from 'totp-generator'; +import { _castAuth } from '../../../internal'; interface IntegrationTestAuth extends Auth { cleanUp(): Promise; } @@ -116,3 +117,37 @@ export const email = 'totpuser-donotdelete@test.com'; export const password = 'password'; //1000000 is always incorrect since it has 7 digits and we expect 6. export const incorrectTotpCode = '1000000'; + +/** + * Generates a valid password for the project or tenant password policy in the Auth instance. + * @param auth The {@link Auth} instance. + * @returns A valid password according to the password policy. + */ +export async function generateValidPassword(auth: Auth): Promise { + if (getEmulatorUrl()) { + return 'password'; + } + + // Fetch the policy using the Auth instance if one is not cached. + const authInternal = _castAuth(auth); + if (!authInternal._getPasswordPolicyInternal()) { + await authInternal._updatePasswordPolicy(); + } + + const passwordPolicy = authInternal._getPasswordPolicyInternal()!; + const options = passwordPolicy.customStrengthOptions; + + // Create a string that satisfies all possible options (uppercase, lowercase, numeric, and special characters). + const nonAlphaNumericCharacter = + passwordPolicy.allowedNonAlphanumericCharacters.charAt(0); + const stringWithAllOptions = 'aA0' + nonAlphaNumericCharacter; + + // Repeat the string enough times to fill up the minimum password length. + const minPasswordLength = options.minPasswordLength ?? 6; + const password = stringWithAllOptions.repeat( + Math.round(minPasswordLength / stringWithAllOptions.length) + ); + + // Return a string that is only as long as the minimum length required by the policy. + return password.substring(0, minPasswordLength); +} diff --git a/packages/auth/test/integration/flows/anonymous.test.ts b/packages/auth/test/integration/flows/anonymous.test.ts index 5fe83e6ede3..1d41423f563 100644 --- a/packages/auth/test/integration/flows/anonymous.test.ts +++ b/packages/auth/test/integration/flows/anonymous.test.ts @@ -65,9 +65,13 @@ describe('Integration test: anonymous auth', () => { context('email/password interaction', () => { let email: string; + let password: string; beforeEach(() => { email = randomEmail(); + password = 'password'; + // Uncomment the following line if you want an autogenerated password that complies with password policy in the test project. + // password = await generateValidPassword(auth); }); it('anonymous / email-password accounts remain independent', async () => { @@ -75,7 +79,7 @@ describe('Integration test: anonymous auth', () => { const emailCred = await createUserWithEmailAndPassword( auth, email, - 'password' + password ); expect(emailCred.user.uid).not.to.eql(anonCred.user.uid); @@ -84,7 +88,7 @@ describe('Integration test: anonymous auth', () => { const emailSignIn = await signInWithEmailAndPassword( auth, email, - 'password' + password ); expect(emailCred.user.uid).to.eql(emailSignIn.user.uid); expect(emailSignIn.user.uid).not.to.eql(anonCred.user.uid); @@ -93,36 +97,36 @@ describe('Integration test: anonymous auth', () => { it('account can be upgraded by setting email and password', async () => { const { user: anonUser } = await signInAnonymously(auth); await updateEmail(anonUser, email); - await updatePassword(anonUser, 'password'); + await updatePassword(anonUser, password); await auth.signOut(); const { user: emailPassUser } = await signInWithEmailAndPassword( auth, email, - 'password' + password ); expect(emailPassUser.uid).to.eq(anonUser.uid); }); it('account can be linked using email and password', async () => { const { user: anonUser } = await signInAnonymously(auth); - const cred = EmailAuthProvider.credential(email, 'password'); + const cred = EmailAuthProvider.credential(email, password); await linkWithCredential(anonUser, cred); await auth.signOut(); const { user: emailPassUser } = await signInWithEmailAndPassword( auth, email, - 'password' + password ); expect(emailPassUser.uid).to.eq(anonUser.uid); }); it('account cannot be linked with existing email/password', async () => { - await createUserWithEmailAndPassword(auth, email, 'password'); + await createUserWithEmailAndPassword(auth, email, password); const { user: anonUser } = await signInAnonymously(auth); - const cred = EmailAuthProvider.credential(email, 'password'); + const cred = EmailAuthProvider.credential(email, password); await expect(linkWithCredential(anonUser, cred)).to.be.rejectedWith( FirebaseError, 'auth/email-already-in-use' diff --git a/packages/auth/test/integration/flows/email.test.ts b/packages/auth/test/integration/flows/email.test.ts index 9da470588b3..792548e4e6b 100644 --- a/packages/auth/test/integration/flows/email.test.ts +++ b/packages/auth/test/integration/flows/email.test.ts @@ -45,9 +45,14 @@ use(chaiAsPromised); describe('Integration test: email/password auth', () => { let auth: Auth; let email: string; + let password: string; + beforeEach(() => { auth = getTestInstance(); email = randomEmail(); + password = 'password'; + // Uncomment the following line if you want an autogenerated password that complies with password policy in the test project. + // password = await generateValidPassword(auth); }); afterEach(() => cleanUpTestInstance(auth)); @@ -56,7 +61,7 @@ describe('Integration test: email/password auth', () => { const userCred = await createUserWithEmailAndPassword( auth, email, - 'password' + password ); expect(auth.currentUser).to.eq(userCred.user); expect(userCred.operationType).to.eq(OperationType.SIGN_IN); @@ -76,9 +81,9 @@ describe('Integration test: email/password auth', () => { }); it('errors when createUser called twice', async () => { - await createUserWithEmailAndPassword(auth, email, 'password'); + await createUserWithEmailAndPassword(auth, email, password); await expect( - createUserWithEmailAndPassword(auth, email, 'password') + createUserWithEmailAndPassword(auth, email, password) ).to.be.rejectedWith(FirebaseError, 'auth/email-already-in-use'); }); @@ -86,11 +91,7 @@ describe('Integration test: email/password auth', () => { let signUpCred: UserCredential; beforeEach(async () => { - signUpCred = await createUserWithEmailAndPassword( - auth, - email, - 'password' - ); + signUpCred = await createUserWithEmailAndPassword(auth, email, password); await auth.signOut(); }); @@ -98,7 +99,7 @@ describe('Integration test: email/password auth', () => { const signInCred = await signInWithEmailAndPassword( auth, email, - 'password' + password ); expect(auth.currentUser).to.eq(signInCred.user); @@ -110,7 +111,7 @@ describe('Integration test: email/password auth', () => { }); it('allows the user to sign in with signInWithCredential', async () => { - const credential = EmailAuthProvider.credential(email, 'password'); + const credential = EmailAuthProvider.credential(email, password); const signInCred = await signInWithCredential(auth, credential); expect(auth.currentUser).to.eq(signInCred.user); @@ -122,7 +123,7 @@ describe('Integration test: email/password auth', () => { }); it('allows the user to update profile', async () => { - let { user } = await signInWithEmailAndPassword(auth, email, 'password'); + let { user } = await signInWithEmailAndPassword(auth, email, password); await updateProfile(user, { displayName: 'Display Name', photoURL: 'photo-url' @@ -132,17 +133,13 @@ describe('Integration test: email/password auth', () => { await auth.signOut(); - user = (await signInWithEmailAndPassword(auth, email, 'password')).user; + user = (await signInWithEmailAndPassword(auth, email, password)).user; expect(user.displayName).to.eq('Display Name'); expect(user.photoURL).to.eq('photo-url'); }); it('allows the user to delete the account', async () => { - const { user } = await signInWithEmailAndPassword( - auth, - email, - 'password' - ); + const { user } = await signInWithEmailAndPassword(auth, email, password); await user.delete(); await expect(reload(user)).to.be.rejectedWith( @@ -152,7 +149,7 @@ describe('Integration test: email/password auth', () => { expect(auth.currentUser).to.be.null; await expect( - signInWithEmailAndPassword(auth, email, 'password') + signInWithEmailAndPassword(auth, email, password) ).to.be.rejectedWith(FirebaseError, 'auth/user-not-found'); }); @@ -160,12 +157,12 @@ describe('Integration test: email/password auth', () => { const { user: userA } = await signInWithEmailAndPassword( auth, email, - 'password' + password ); const { user: userB } = await signInWithEmailAndPassword( auth, email, - 'password' + password ); expect(userA.uid).to.eq(userB.uid); }); @@ -173,7 +170,7 @@ describe('Integration test: email/password auth', () => { generateMiddlewareTests( () => auth, () => { - return signInWithEmailAndPassword(auth, email, 'password'); + return signInWithEmailAndPassword(auth, email, password); } ); }); diff --git a/packages/auth/test/integration/flows/middleware_test_generator.ts b/packages/auth/test/integration/flows/middleware_test_generator.ts index 16e884ce234..ae0ec1fa4e2 100644 --- a/packages/auth/test/integration/flows/middleware_test_generator.ts +++ b/packages/auth/test/integration/flows/middleware_test_generator.ts @@ -34,10 +34,14 @@ export function generateMiddlewareTests( context('middleware', () => { let auth: Auth; let unsubscribes: Array<() => void>; + let password: string; beforeEach(() => { auth = authGetter(); unsubscribes = []; + password = 'password'; + // Uncomment the following line if you want an autogenerated password that complies with password policy in the test project. + // password = await generateValidPassword(auth); }); afterEach(() => { @@ -81,7 +85,7 @@ export function generateMiddlewareTests( const { user: baseUser } = await createUserWithEmailAndPassword( auth, randomEmail(), - 'password' + password ); beforeAuthStateChanged(() => { @@ -115,7 +119,7 @@ export function generateMiddlewareTests( const { user: baseUser } = await createUserWithEmailAndPassword( auth, randomEmail(), - 'password' + password ); beforeAuthStateChanged(() => { @@ -148,7 +152,7 @@ export function generateMiddlewareTests( const { user: baseUser } = await createUserWithEmailAndPassword( auth, randomEmail(), - 'password' + password ); // Also check that the function is called multiple @@ -172,7 +176,7 @@ export function generateMiddlewareTests( const { user: baseUser } = await createUserWithEmailAndPassword( auth, randomEmail(), - 'password' + password ); // Also check that the function is called multiple diff --git a/packages/auth/test/integration/flows/password_policy.test.ts b/packages/auth/test/integration/flows/password_policy.test.ts new file mode 100644 index 00000000000..5ad73976f39 --- /dev/null +++ b/packages/auth/test/integration/flows/password_policy.test.ts @@ -0,0 +1,119 @@ +/** + * @license + * Copyright 2023 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. + */ + +// eslint-disable-next-line import/no-extraneous-dependencies +import { Auth, validatePassword } from '@firebase/auth'; +import { expect, use } from 'chai'; +import chaiAsPromised from 'chai-as-promised'; +import { + cleanUpTestInstance, + generateValidPassword, + getTestInstance +} from '../../helpers/integration/helpers'; +import { getEmulatorUrl } from '../../helpers/integration/settings'; +import { PasswordPolicyCustomStrengthOptions } from '../../../src/model/password_policy'; + +use(chaiAsPromised); + +describe('Integration test: password validation', () => { + let auth: Auth; + + const TEST_TENANT_ID = 'passpol-tenant-d7hha'; + const EXPECTED_TENANT_CUSTOM_STRENGTH_OPTIONS: PasswordPolicyCustomStrengthOptions = + { + minPasswordLength: 8, + maxPasswordLength: 24, + containsLowercaseLetter: true, + containsUppercaseLetter: true, + containsNumericCharacter: true, + containsNonAlphanumericCharacter: true + }; + + beforeEach(function () { + auth = getTestInstance(); + + if (getEmulatorUrl()) { + this.skip(); + } + }); + + afterEach(async () => { + await cleanUpTestInstance(auth); + }); + + context('validatePassword', () => { + // Password will always be invalid since the minimum min length is 6. + const INVALID_PASSWORD = 'a'; + const TENANT_PARTIALLY_INVALID_PASSWORD = 'Password0123'; + + it('considers valid passwords valid against the policy configured for the project', async () => { + const password = await generateValidPassword(auth); + expect((await validatePassword(auth, password)).isValid).to.be.true; + }); + + it('considers invalid passwords invalid against the policy configured for the project', async () => { + // Even if there is no policy configured for the project, a minimum length of 6 will always be enforced. + expect((await validatePassword(auth, INVALID_PASSWORD)).isValid).to.be + .false; + }); + + it('considers valid passwords valid against the policy configured for the tenant', async () => { + auth.tenantId = TEST_TENANT_ID; + const password = await generateValidPassword(auth); + const status = await validatePassword(auth, password); + + expect(status.isValid).to.be.true; + expect(status.meetsMinPasswordLength).to.be.true; + expect(status.meetsMaxPasswordLength).to.be.true; + expect(status.containsLowercaseLetter).to.be.true; + expect(status.containsUppercaseLetter).to.be.true; + expect(status.containsNumericCharacter).to.be.true; + expect(status.containsNonAlphanumericCharacter).to.be.true; + }); + + it('considers invalid passwords invalid against the policy configured for the tenant', async () => { + auth.tenantId = TEST_TENANT_ID; + let status = await validatePassword(auth, INVALID_PASSWORD); + + expect(status.isValid).to.be.false; + expect(status.meetsMinPasswordLength).to.be.false; + expect(status.meetsMaxPasswordLength).to.be.true; + expect(status.containsLowercaseLetter).to.be.true; + expect(status.containsUppercaseLetter).to.be.false; + expect(status.containsNumericCharacter).to.be.false; + expect(status.containsNonAlphanumericCharacter).to.be.false; + + status = await validatePassword(auth, TENANT_PARTIALLY_INVALID_PASSWORD); + + expect(status.isValid).to.be.false; + expect(status.meetsMinPasswordLength).to.be.true; + expect(status.meetsMaxPasswordLength).to.be.true; + expect(status.containsLowercaseLetter).to.be.true; + expect(status.containsUppercaseLetter).to.be.true; + expect(status.containsNumericCharacter).to.be.true; + expect(status.containsNonAlphanumericCharacter).to.be.false; + }); + + it('includes the password policy strength options in the returned status', async () => { + auth.tenantId = TEST_TENANT_ID; + const status = await validatePassword(auth, INVALID_PASSWORD); + expect(status.passwordPolicy.customStrengthOptions).to.eql( + EXPECTED_TENANT_CUSTOM_STRENGTH_OPTIONS + ); + }); + }); +}); From 43e402fb49a081a59729290627c7b20099ca46a4 Mon Sep 17 00:00:00 2001 From: Tom Andersen Date: Tue, 8 Aug 2023 11:01:15 -0400 Subject: [PATCH 08/15] fix: Update proto loader (#7520) * Update proto loader * Update proto loader * Update proto loader * Update proto loader --- .changeset/heavy-dingos-obey.md | 5 +++++ packages/firestore/package.json | 2 +- yarn.lock | 28 ++++++++++++++++++++++++++-- 3 files changed, 32 insertions(+), 3 deletions(-) create mode 100644 .changeset/heavy-dingos-obey.md diff --git a/.changeset/heavy-dingos-obey.md b/.changeset/heavy-dingos-obey.md new file mode 100644 index 00000000000..0368f49122c --- /dev/null +++ b/.changeset/heavy-dingos-obey.md @@ -0,0 +1,5 @@ +--- +'@firebase/firestore': patch +--- + +Update @grpc/proto-loader from v0.6.13 to v0.7.8 diff --git a/packages/firestore/package.json b/packages/firestore/package.json index 62e305cf5ec..933a82ecf11 100644 --- a/packages/firestore/package.json +++ b/packages/firestore/package.json @@ -101,7 +101,7 @@ "@firebase/util": "1.9.3", "@firebase/webchannel-wrapper": "0.10.1", "@grpc/grpc-js": "~1.8.17", - "@grpc/proto-loader": "^0.6.13", + "@grpc/proto-loader": "^0.7.8", "node-fetch": "2.6.7", "tslib": "^2.1.0" }, diff --git a/yarn.lock b/yarn.lock index ee6332eb671..4ff933dde34 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2003,7 +2003,7 @@ "@grpc/proto-loader" "^0.7.0" "@types/node" ">=12.12.47" -"@grpc/proto-loader@^0.6.12", "@grpc/proto-loader@^0.6.13", "@grpc/proto-loader@^0.6.4": +"@grpc/proto-loader@^0.6.12", "@grpc/proto-loader@^0.6.4": version "0.6.13" resolved "https://registry.npmjs.org/@grpc/proto-loader/-/proto-loader-0.6.13.tgz#008f989b72a40c60c96cd4088522f09b05ac66bc" integrity sha512-FjxPYDRTn6Ec3V0arm1FtSpmP6V50wuph2yILpyvTKzjc76oDdoihXqM1DzOW5ubvCC8GivfCnNtfaRE8myJ7g== @@ -2025,6 +2025,17 @@ protobufjs "^7.0.0" yargs "^16.2.0" +"@grpc/proto-loader@^0.7.8": + version "0.7.8" + resolved "https://registry.npmjs.org/@grpc/proto-loader/-/proto-loader-0.7.8.tgz#c050bbeae5f000a1919507f195a1b094e218036e" + integrity sha512-GU12e2c8dmdXb7XUlOgYWZ2o2i+z9/VeACkxTA/zzAe2IjclC5PnVL0lpgjhrqfpDYHzM8B1TF6pqWegMYAzlA== + dependencies: + "@types/long" "^4.0.1" + lodash.camelcase "^4.3.0" + long "^4.0.0" + protobufjs "^7.2.4" + yargs "^17.7.2" + "@gulp-sourcemaps/identity-map@^2.0.1": version "2.0.1" resolved "https://registry.npmjs.org/@gulp-sourcemaps/identity-map/-/identity-map-2.0.1.tgz#a6e8b1abec8f790ec6be2b8c500e6e68037c0019" @@ -14517,7 +14528,7 @@ protobufjs@6.11.3, protobufjs@^6.11.3: "@types/node" ">=13.7.0" long "^4.0.0" -protobufjs@7.2.4, protobufjs@^7.0.0: +protobufjs@7.2.4, protobufjs@^7.0.0, protobufjs@^7.2.4: version "7.2.4" resolved "https://registry.npmjs.org/protobufjs/-/protobufjs-7.2.4.tgz#3fc1ec0cdc89dd91aef9ba6037ba07408485c3ae" integrity sha512-AT+RJgD2sH8phPmCf7OUZR8xGdcJRga4+1cOaXJ64hvcSkVhNcRHOwIxUatPH15+nj59WAGTDv3LSGZPEQbJaQ== @@ -18655,6 +18666,19 @@ yargs@^17.1.1: y18n "^5.0.5" yargs-parser "^20.2.2" +yargs@^17.7.2: + version "17.7.2" + resolved "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz#991df39aca675a192b816e1e0363f9d75d2aa269" + integrity sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w== + dependencies: + cliui "^8.0.1" + escalade "^3.1.1" + get-caller-file "^2.0.5" + require-directory "^2.1.1" + string-width "^4.2.3" + y18n "^5.0.5" + yargs-parser "^21.1.1" + yargs@^7.1.0: version "7.1.2" resolved "https://registry.npmjs.org/yargs/-/yargs-7.1.2.tgz#63a0a5d42143879fdbb30370741374e0641d55db" From 6c7d079231f393196aa68ef8d6463dc32ffce798 Mon Sep 17 00:00:00 2001 From: renkelvin Date: Tue, 8 Aug 2023 11:27:07 -0700 Subject: [PATCH 09/15] Raise error if calling initializeRecaptchaConfig in node env (#7284) --- .changeset/short-dogs-smell.md | 5 +++++ packages/auth/src/platform_node/index.ts | 1 + 2 files changed, 6 insertions(+) create mode 100644 .changeset/short-dogs-smell.md diff --git a/.changeset/short-dogs-smell.md b/.changeset/short-dogs-smell.md new file mode 100644 index 00000000000..0b27623cb07 --- /dev/null +++ b/.changeset/short-dogs-smell.md @@ -0,0 +1,5 @@ +--- +'@firebase/auth': patch +--- + +Raise error if calling initializeRecaptchaConfig in node env diff --git a/packages/auth/src/platform_node/index.ts b/packages/auth/src/platform_node/index.ts index 570f1e0589f..043ba3ccd70 100644 --- a/packages/auth/src/platform_node/index.ts +++ b/packages/auth/src/platform_node/index.ts @@ -101,6 +101,7 @@ export const linkWithRedirect = fail; export const reauthenticateWithRedirect = fail; export const getRedirectResult = fail; export const RecaptchaVerifier = FailClass; +export const initializeRecaptchaConfig = fail; export class PhoneMultiFactorGenerator { static assertion(): unknown { throw NOT_AVAILABLE_ERROR; From f73b24d0ec0fdc8dea3a8e1c141797d3e7466915 Mon Sep 17 00:00:00 2001 From: Mila <107142260+milaGGL@users.noreply.github.com> Date: Tue, 8 Aug 2023 15:40:22 -0400 Subject: [PATCH 10/15] Skip large sized bloom filter golden tests (#7537) --- .../test/unit/remote/bloom_filter.test.ts | 43 ++++++++++++------- 1 file changed, 28 insertions(+), 15 deletions(-) diff --git a/packages/firestore/test/unit/remote/bloom_filter.test.ts b/packages/firestore/test/unit/remote/bloom_filter.test.ts index fd0b66e7d68..eae3e5853ac 100644 --- a/packages/firestore/test/unit/remote/bloom_filter.test.ts +++ b/packages/firestore/test/unit/remote/bloom_filter.test.ts @@ -198,25 +198,38 @@ describe('BloomFilter', () => { TEST_DATA.count5000Rate0001TestResult ); }); - it('mightContain result for 50000 documents with 1 false positive rate', () => { + + // Skip large sized golden tests as they slow down unit test runs without significantly + // improving coverage compared to other golden tests. + // These tests can be run manually if needed. + // eslint-disable-next-line no-restricted-properties + it.skip('mightContain result for 50000 documents with 1 false positive rate', () => { testBloomFilterAgainstExpectedResult( TEST_DATA.count50000Rate1TestData, TEST_DATA.count50000Rate1TestResult ); }); - it('mightContain result for 50000 documents with 0.01 false positive rate', () => { - testBloomFilterAgainstExpectedResult( - TEST_DATA.count50000Rate01TestData, - TEST_DATA.count50000Rate01TestResult - ); - //Extend default timeout(2000) - }).timeout(10_000); - it('mightContain result for 50000 documents with 0.0001 false positive rate', () => { - testBloomFilterAgainstExpectedResult( - TEST_DATA.count50000Rate0001TestData, - TEST_DATA.count50000Rate0001TestResult - ); - //Extend default timeout(2000) - }).timeout(10_000); + // eslint-disable-next-line no-restricted-properties + it.skip( + 'mightContain result for 50000 documents with 0.01 false positive rate', + () => { + testBloomFilterAgainstExpectedResult( + TEST_DATA.count50000Rate01TestData, + TEST_DATA.count50000Rate01TestResult + ); + // Extend the default timeout to 10000ms + } + ).timeout(10_000); + // eslint-disable-next-line no-restricted-properties + it.skip( + 'mightContain result for 50000 documents with 0.0001 false positive rate', + () => { + testBloomFilterAgainstExpectedResult( + TEST_DATA.count50000Rate0001TestData, + TEST_DATA.count50000Rate0001TestResult + ); + // Extend the default timeout to 10000ms + } + ).timeout(10_000); }); }); From 040b0b450117f3bd64317e72d16a812d6adfd10f Mon Sep 17 00:00:00 2001 From: DellaBitta Date: Thu, 10 Aug 2023 10:21:37 -0400 Subject: [PATCH 11/15] Updates to our contributing guidance (#7526) Review and update of the CONTRIBUTING.md guide. --- CONTRIBUTING.md | 174 +++++++++++++++++++++++++++++++++++------------- 1 file changed, 127 insertions(+), 47 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8684de9a07e..83109f361d4 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,6 +1,7 @@ # Contributing to the Firebase JS SDK -We'd love for you to contribute to our source code and to help make the Firebase JS SDK even better than it is today! Here are the guidelines we'd like you to follow: +We'd love for you to contribute to our source code and to help make the Firebase JS SDK even better +than it is today! Here are the guidelines we'd like you to follow: - [Code of Conduct](#coc) - [Question or Problem?](#question) @@ -11,46 +12,60 @@ We'd love for you to contribute to our source code and to help make the Firebase ## Code of Conduct -As contributors and maintainers of the Firebase JS SDK project, we pledge to respect everyone who contributes by posting issues, updating documentation, submitting pull requests, providing feedback in comments, and any other activities. +As contributors and maintainers of the Firebase JS SDK project, we pledge to respect everyone who +contributes by posting issues, updating documentation, submitting pull requests, providing feedback +in comments, and any other activities. -Communication through any of Firebase's channels (GitHub, StackOverflow, Google+, Twitter, etc.) must be constructive and never resort to personal attacks, trolling, public or private harassment, insults, or other unprofessional conduct. +Communication through any of Firebase's channels (GitHub, StackOverflow, Google+, Twitter, etc.) +must be constructive and never resort to personal attacks, trolling, public or private harassment, +insults, or other unprofessional conduct. -We promise to extend courtesy and respect to everyone involved in this project regardless of gender, gender identity, sexual orientation, disability, age, race, ethnicity, religion, or level of experience. We expect anyone contributing to the project to do the same. +We promise to extend courtesy and respect to everyone involved in this project regardless of gender, +gender identity, sexual orientation, disability, age, race, ethnicity, religion, or level of +experience. We expect anyone contributing to the project to do the same. -If any member of the community violates this code of conduct, the maintainers of the Firebase JS SDK project may take action, removing issues, comments, and PRs or blocking accounts as deemed appropriate. +If any member of the community violates this code of conduct, the maintainers of the Firebase JS SDK +project may take action, removing issues, comments, and PRs or blocking accounts as deemed +appropriate. -If you are subject to or witness unacceptable behavior, or have any other concerns, please drop us a line at firebase-code-of-conduct@google.com. +If you are subject to or witness unacceptable behavior, or have any other concerns, please drop us a +line at firebase-code-of-conduct@google.com. ## Got a Question? -If you have questions about how to use the Firebase JS SDK, please direct these to [StackOverflow][stackoverflow] and use the `firebase` and `javascript` tags. You can also use the [Firebase Google Group][firebase-google-group] or [Slack][slack] to contact members of the Firebase team for help. +If you have questions about how to use the Firebase JS SDK, please direct these to +[StackOverflow][stackoverflow] and use the `firebase` and `javascript` tags. You can also use the +[Firebase Google Group][firebase-google-group] or [Slack][slack] to contact members of the Firebase +team for help. ## Found an Issue? -If you find a bug in the source code, a mistake in the documentation, or some other issue, you can help us by submitting an issue to our [GitHub Repository][github]. Even better you can submit a Pull Request with a test demonstrating the bug and a fix! +If you find a bug in the source code, a mistake in the documentation, or some other issue, you can +help us by submitting an issue to our [GitHub Repository][github]. Even better you can submit a Pull +Request with a test demonstrating the bug and a fix! See [below](#submit) for some guidelines. ## Production Issues -If you have a production issue, please [contact Firebase support][support] who will work with you to resolve the issue. +If you have a production issue, please [contact Firebase support][support] who will work with you to +resolve the issue. ## Submission Guidelines ### Submitting an Issue -Before you submit your issue, try searching [past issues][archive], [StackOverflow][stackoverflow], and the [Firebase Google Group][firebase-google-group] for issues similar to your own. You can help us to maximize the effort we spend fixing issues, and adding new features, by not reporting duplicate issues. +Before you submit your issue, try searching [past issues][archive], [StackOverflow][stackoverflow], +and the [Firebase Google Group][firebase-google-group] for issues similar to your own. You can help +us to maximize the effort we spend fixing issues, and adding new features, by not reporting +duplicate issues. -If your issue appears to be a bug, and hasn't been reported, open a new issue. Providing the following information will increase the chances of your issue being dealt with quickly: - -* **Description of the Issue** - if an error is being thrown a non-minified stack trace helps -* **Motivation for or Use Case** - explain why this is a bug for you -* **Related Issues** - has a similar issue been reported before? -* **Environment Configuration** - is this a problem with Node.js, or only a specific browser? Is this only in a specific version of the SDK? -* **Reproduce the Error** - provide a live example (like [JSBin][jsbin]), a Github repo, or an unambiguous set of steps -* **Suggest a Fix** - if you can't fix the bug yourself, perhaps you can point to what might be causing the problem (line of code or commit) - -There is an issue template provided to help capture all of this information. Following the template will also help us to route your issue to the appropriate teams faster, helping us to better help you! +If you encounter an issue that appears to be a bug that has not been reported before, please +[open a new issue in the repo](https://github.com/firebase/firebase-js-sdk/issues/new/choose). When +filling out the new issue report form, be sure to include as much information as possible, such as +reproduction steps, the error message you received, and any screenshots or other relevant data. The +more context you can provide the better we will be able to understand the issue, route it to the +appropriate team, and provide you with the help you need. Also as a great rule of thumb: @@ -60,29 +75,49 @@ Also as a great rule of thumb: #### Before you contribute -Before we can use your code, you must sign the [Google Individual Contributor License Agreement][google-cla] (CLA), which you can do online. The CLA is necessary mainly because you own the copyright to your changes, even after your contribution becomes part of our codebase, so we need your permission to use and distribute your code. We also need to be sure of various other things, for instance, that you'll tell us if you know that your code infringes on other people's patents. You don't have to sign the CLA until after you've submitted your code for review and a member has approved it, but you must do it before we can put your code into our codebase. There is also a nifty CLA bot that will guide you through this process if you are going through it for the first time. - -Before you start working on a larger contribution, you should get in touch with us first through the issue tracker with your idea so that we can help out and possibly guide you. Coordinating up front makes it much easier to avoid frustration later on. Some pull requests (large contributions, API additions/changes, etc) may be subject to additional internal review, we appreciate your patience as we fully validate your contribution. +Before we can use your code, you must sign the +[Google Individual Contributor License Agreement][google-cla] (CLA), which you can do online. The +CLA is necessary mainly because you own the copyright to your changes, even after your contribution +becomes part of our codebase, so we need your permission to use and distribute your code. We also +need to be sure of various other things, for instance, that you'll tell us if you know that your +code infringes on other people's patents. You don't have to sign the CLA until after you've +submitted your code for review and a member has approved it, but you must do it before we can put +your code into our codebase. There is also a nifty CLA bot that will guide you through this process +if you are going through it for the first time. + +Before you start working on a larger contribution, you should get in touch with us first through the +issue tracker with your idea so that we can help out and possibly guide you. Coordinating up front +makes it much easier to avoid frustration later on. Some pull requests (large contributions, API +additions/changes, etc) may be subject to additional internal review, we appreciate your patience as +we fully validate your contribution. #### Pull Request Guidelines -* Search [GitHub](https://github.com/firebase/firebase-js-sdk/pulls) for an open or closed Pull Request that relates to your submission. You don't want to duplicate effort. -* Create an issue to discuss a change before submitting a PR. We'd hate to have to turn down your contributions because of something that could have been communicated early on. -* [Create a fork of the GitHub repo][fork-repo] to ensure that you can push your changes for us to review. +* Search [GitHub](https://github.com/firebase/firebase-js-sdk/pulls) for an open or closed Pull +Request that relates to your submission. You don't want to duplicate effort. +* Create an issue to discuss a change before submitting a PR. We'd hate to have to turn down your +contributions because of something that could have been communicated early on. +* [Create a fork of the GitHub repo][fork-repo] to ensure that you can push your changes for us to +review. * Make your changes in a new git branch: ```shell git checkout -b my-fix-branch master ``` -* Create your patch, **including appropriate test cases**. Patches with tests are more likely to be merged. -* Avoid checking in files that shouldn't be tracked (e.g `node_modules`, `gulp-cache`, `.tmp`, `.idea`). If your development setup automatically creates some of these files, please add them to the `.gitignore` at the root of the package (click [here][gitignore] to read more on how to add entries to the `.gitignore`). +* Create your change, **including appropriate test cases**. Changes with tests are more likely to be +merged. +* Avoid checking in files that shouldn't be tracked (e.g `node_modules`, `gulp-cache`, `.tmp`, +`.idea`). If your development setup automatically creates some of these files, please add them to +the `.gitignore` at the root of the package (click [here][gitignore] to read more on how to add +entries to the `.gitignore`). * Commit your changes ```shell git commit -a ``` - _Note: the optional commit `-a` command line option will automatically "add" and "rm" edited files._ + _Note: the optional commit `-a` command line option will automatically "add" and "rm" edited + files._ * Test your changes locally to ensure everything is in good working order: @@ -96,25 +131,40 @@ Before you start working on a larger contribution, you should get in touch with git push origin my-fix-branch ``` -* In GitHub, send a pull request to `firebase-js-sdk:master`. -* Add changeset. See [Adding changeset to PR](#adding-changeset-to-pr) -* All pull requests must be reviewed by a member of the Firebase JS SDK team, who will merge it when/if they feel it is good to go. +* In GitHub, create a pull request against the `firebase-js-sdk:master` branch. +* Add changeset. See [Adding changeset to PR](#adding-changeset-to-pr). +* All pull requests must be reviewed by a member of the Firebase JS SDK team, who will merge it +when/if they feel it is good to go. That's it! Thank you for your contribution! #### Adding changeset to PR -Every PR that would trigger a release should include a changeset file. To make -this process easy, a message will be sent to every PR with a link that you can -click to add changeset files in the Github UI directly. -[Example message](https://github.com/firebase/firebase-js-sdk/pull/3284#issuecomment-649718617). +The repository uses changesets to associate PR contributions with major and minor version releases +adn patch releases. If your change is a feature or a behavioral change (either of which should +correspond to a version bump) then you will need to generate a changeset in your PR to track the +change. + +Start the changeset creation process by running the following command in the base directory of the +repository: + +```shell +yarn changeset +``` -#### What to include in the changset file +You will be asked to create a description (here's an +[example]((https://github.com/firebase/firebase-js-sdk/pull/3284#issuecomment-649718617)). You +should include the version bump for your package as well as the description for the change. Valid +version bump types are major, minor or patch, where: -You should include the version bump for your package as well as the description -for the change. Valid version bump types are `patch`, `minor` and `major`. -Please always include the `firebase` package with the same version bump type as -your package. This is to ensure that the version of the `firebase` package will -be bumped correctly. For example, + * a major version is an incompatible API change + * a minor version is a backwards compatible API change + * a patch version is a backwards compatible bug fix or any change that does not affect the API. A + refactor, for example. + +Please always include the firebase package with the same version bump type as your package. This is +to ensure that the version of the firebase package will be bumped correctly, + + For example, ``` --- @@ -125,6 +175,12 @@ be bumped correctly. For example, This is a test. ``` +You do not need to create a Changeset for the following changes: + + * the addition or alteration of a test + * documentation updates + * updates to the repository’s CI + #### Multiple changeset files If your PR touches multiple SDKs or addresses multiple issues that require @@ -133,14 +189,31 @@ changeset files in the PR. ## Updating Documentation -Reference docs for the Firebase [JS SDK](https://firebase.google.com/docs/reference/js/) and [Node (client) SDK](https://firebase.google.com/docs/reference/node/) are generated by [Typedoc](https://typedoc.org/). +Reference docs for the Firebase [JS SDK](https://firebase.google.com/docs/reference/js/) and +[Node (client) SDK](https://firebase.google.com/docs/reference/node/) are generated by +[Typedoc](https://typedoc.org/). + +Typedoc generates this documentation from the main +[firebase index.d.ts type definition file](packages/firebase/index.d.ts). Any updates to +documentation should be made in that file. -Typedoc generates this documentation from the main [firebase index.d.ts type definition file](packages/firebase/index.d.ts). Any updates to documentation should be made in that file. +If any pages are added or removed by your change (by adding or removing a class or interface), the +[js/toc.yaml](scripts/docgen/content-sources/js/toc.yaml) and/or +[node/toc.yaml](scripts/docgen/content-sources/node/toc.yaml) need to be modified to reflect this. -If any pages are added or removed by your change (by adding or removing a class or interface), the [js/toc.yaml](scripts/docgen/content-sources/js/toc.yaml) and/or [node/toc.yaml](scripts/docgen/content-sources/node/toc.yaml) need to be modified to reflect this. +# Formatting Code +A Formatting Check CI failure in your PR indicates that the code does not follow the repo's +formatting guidelines. In your local build environment, please run the code formatting tool locally +by executing the command `yarn format`. Once the code is formatted, commit the changes and push your +branch. The push should cause the CI to re-check your PR's changes. ### Generating Documentation HTML Files +If the Doc Change Check fails in your PR, it indicates that the documentation has not been generated +correctly for the changes. In your local build environment, please run `yarn docgen devsite` to +generate the documentation locally. Once the documentation has been generated, commit the changes +and push your branch. The push should cause the CI to re-check your PR's changes. + In order to generate the HTML documentation files locally, go to the root of this repo, and run: ``` @@ -164,9 +237,16 @@ yarn docgen:node Files will be written to `scripts/docgen/html` - js docs will go into the `/js` subdirectory and node docs into the `/node` subdirectory. -**NOTE:** These files are formatted to be inserted into Google's documentation site, which adds some styling and navigation, so the raw files will be missing navigation elements and may not look polished. However, it should be enough to preview the content. +**NOTE:** These files are formatted to be inserted into Google's documentation site, which adds some +styling and navigation, so the raw files will be missing navigation elements and may not look +polished. However, it should be enough to preview the content. -This process will generate warnings for files that are generated but not listed in the `toc.yaml`, or files that are in the `toc.yaml` but were not generated (which means something is missing in `index.d.ts`). If this happens during the JS documentation generation, it probably means either the `toc.yaml` or `index.d.ts` is incorrect. But in the Node process, some generated files not being found in `toc.yaml` are to be expected, since Node documentation is a subset of the full JS documentation. +This process will generate warnings for files that are generated but not listed in the `toc.yaml`, +or files that are in the `toc.yaml` but were not generated (which means something is missing in +`index.d.ts`). If this happens during the JS documentation generation, it probably means either the +`toc.yaml` or `index.d.ts` is incorrect. But in the Node process, some generated files not being +found in `toc.yaml` are to be expected, since Node documentation is a subset of the full JS +documentation. Follow the [PR submission guidelines](#submit) above to submit any documentation changes. From f497a400a5503db8d807f65a3f3466b8f73cb077 Mon Sep 17 00:00:00 2001 From: wu-hui <53845758+wu-hui@users.noreply.github.com> Date: Mon, 14 Aug 2023 13:06:33 -0400 Subject: [PATCH 12/15] Fix and skip flaky Firestore tests (#7552) * Fix and skip flaky tests * add todos * more nits --- .../test/integration/api/database.test.ts | 16 ++++++++++------ .../integration/api/numeric_transforms.test.ts | 11 +++++++++-- .../firestore/test/integration/api/query.test.ts | 6 +++++- 3 files changed, 24 insertions(+), 9 deletions(-) diff --git a/packages/firestore/test/integration/api/database.test.ts b/packages/firestore/test/integration/api/database.test.ts index f7b1f9cd250..2b5faff54e5 100644 --- a/packages/firestore/test/integration/api/database.test.ts +++ b/packages/firestore/test/integration/api/database.test.ts @@ -798,9 +798,11 @@ apiDescribe('Database', persistence => { .then(snap => { expect(snap.exists()).to.be.true; expect(snap.data()).to.deep.equal({ a: 1 }); - expect(snap.metadata.hasPendingWrites).to.be.false; - }) - .then(() => storeEvent.assertNoAdditionalEvents()); + // This event could be a metadata change for fromCache as well. + // We comment this line out to reduce flakiness. + // TODO(b/295872012): Figure out a way to check for all scenarios. + // expect(snap.metadata.hasPendingWrites).to.be.false; + }); }); }); @@ -827,9 +829,11 @@ apiDescribe('Database', persistence => { .then(() => storeEvent.awaitEvent()) .then(snap => { expect(snap.data()).to.deep.equal(changedData); - expect(snap.metadata.hasPendingWrites).to.be.false; - }) - .then(() => storeEvent.assertNoAdditionalEvents()); + // This event could be a metadata change for fromCache as well. + // We comment this line out to reduce flakiness. + // TODO(b/295872012): Figure out a way to check for all scenarios. + // expect(snap.metadata.hasPendingWrites).to.be.false; + }); }); }); diff --git a/packages/firestore/test/integration/api/numeric_transforms.test.ts b/packages/firestore/test/integration/api/numeric_transforms.test.ts index 374be8e5e71..dadeff1c5b3 100644 --- a/packages/firestore/test/integration/api/numeric_transforms.test.ts +++ b/packages/firestore/test/integration/api/numeric_transforms.test.ts @@ -102,7 +102,10 @@ apiDescribe('Numeric Transforms:', persistence => { }); }); - it('increment existing integer with integer', async () => { + // TODO(b/295872012): This test is skipped due to a timeout test flakiness + // We should investigate if this is an acutal bug. + // eslint-disable-next-line no-restricted-properties + it.skip('increment existing integer with integer', async () => { await withTestSetup(async () => { await writeInitialData({ sum: 1337 }); await updateDoc(docRef, 'sum', increment(1)); @@ -158,7 +161,11 @@ apiDescribe('Numeric Transforms:', persistence => { }); }); - it('multiple double increments', async () => { + // TODO(b/295872012): This test is skipped due to test flakiness: + // AssertionError: expected 0.122 to be close to 0.111 +/- 0.000001 + // We should investigate the root cause, it might be an acutal bug. + // eslint-disable-next-line no-restricted-properties + it.skip('multiple double increments', async () => { await withTestSetup(async () => { await writeInitialData({ sum: 0.0 }); diff --git a/packages/firestore/test/integration/api/query.test.ts b/packages/firestore/test/integration/api/query.test.ts index 92662596bc7..0363eb53b3f 100644 --- a/packages/firestore/test/integration/api/query.test.ts +++ b/packages/firestore/test/integration/api/query.test.ts @@ -444,7 +444,11 @@ apiDescribe('Queries', persistence => { }); }); - it('can listen for the same query with different options', () => { + // TODO(b/295872012): This test is skipped due to the flakiness around the + // checks of hasPendingWrites. + // We should investigate if this is an acutal bug. + // eslint-disable-next-line no-restricted-properties + it.skip('can listen for the same query with different options', () => { const testDocs = { a: { v: 'a' }, b: { v: 'b' } }; return withTestCollection(persistence, testDocs, coll => { const storeEvent = new EventsAccumulator(); From 89fb9fd1716c78a5d23dfd4b1e654f741bde5567 Mon Sep 17 00:00:00 2001 From: Christina Holland Date: Mon, 14 Aug 2023 12:36:58 -0700 Subject: [PATCH 13/15] Upgrade webpack to v5 (#7540) --- config/webpack.test.js | 9 +- integration/firebase/test/namespace.test.ts | 6 +- integration/firestore/package.json | 2 +- package.json | 9 +- packages/auth-compat/test/helpers/helpers.ts | 1 + .../auth/test/helpers/integration/helpers.ts | 2 +- patches/karma-webpack+5.0.0.patch | 23 ++ repo-scripts/size-analysis/package.json | 2 +- yarn.lock | 279 +++++++++++++++--- 9 files changed, 281 insertions(+), 52 deletions(-) create mode 100644 patches/karma-webpack+5.0.0.patch diff --git a/config/webpack.test.js b/config/webpack.test.js index dd1e5dbf188..7f71079b2de 100644 --- a/config/webpack.test.js +++ b/config/webpack.test.js @@ -17,6 +17,7 @@ const path = require('path'); const webpack = require('webpack'); +const NodePolyfillPlugin = require('node-polyfill-webpack-plugin'); /** * A regular expression used to replace Firestore's and Storage's platform- @@ -28,6 +29,11 @@ const PLATFORM_RE = /^(.*)\/platform\/([^.\/]*)(\.ts)?$/; module.exports = { mode: 'development', devtool: 'source-map', + optimization: { + runtimeChunk: false, + splitChunks: false, + minimize: false + }, module: { rules: [ { @@ -98,7 +104,7 @@ module.exports = { modules: ['node_modules', path.resolve(__dirname, '../../node_modules')], mainFields: ['browser', 'module', 'main'], extensions: ['.js', '.ts'], - symlinks: false + symlinks: true }, plugins: [ new webpack.NormalModuleReplacementPlugin(PLATFORM_RE, resource => { @@ -108,6 +114,7 @@ module.exports = { `$1/platform/${targetPlatform}/$2.ts` ); }), + new NodePolyfillPlugin(), new webpack.EnvironmentPlugin([ 'RTDB_EMULATOR_PORT', 'RTDB_EMULATOR_NAMESPACE' diff --git a/integration/firebase/test/namespace.test.ts b/integration/firebase/test/namespace.test.ts index a0f1c782292..052044935ed 100644 --- a/integration/firebase/test/namespace.test.ts +++ b/integration/firebase/test/namespace.test.ts @@ -15,7 +15,11 @@ * limitations under the License. */ -import firebase from 'firebase/compat'; +import firebase from 'firebase/compat/app'; +import 'firebase/compat/auth'; +import 'firebase/compat/database'; +import 'firebase/compat/storage'; +import 'firebase/compat/messaging'; import * as namespaceDefinition from './namespaceDefinition.json'; import validateNamespace from './validator'; diff --git a/integration/firestore/package.json b/integration/firestore/package.json index d9ec4d96f12..e6e1276db06 100644 --- a/integration/firestore/package.json +++ b/integration/firestore/package.json @@ -31,7 +31,7 @@ "mocha": "9.2.2", "ts-loader": "8.4.0", "typescript": "4.2.2", - "webpack": "4.46.0", + "webpack": "5.76.0", "webpack-stream": "6.1.2" } } diff --git a/package.json b/package.json index 15a56b3c3ac..e733234b233 100644 --- a/package.json +++ b/package.json @@ -45,7 +45,7 @@ "size-report": "ts-node-script scripts/size_report/report_binary_size.ts", "modular-export-size-report": "ts-node-script scripts/size_report/report_modular_export_binary_size.ts", "api-report": "lerna run --scope @firebase/* api-report", - "postinstall": "yarn --cwd repo-scripts/changelog-generator build", + "postinstall": "patch-package && yarn --cwd repo-scripts/changelog-generator build", "sa": "ts-node-script repo-scripts/size-analysis/cli.ts", "api-documenter-devsite": "ts-node-script repo-scripts/api-documenter/src/start.ts", "format": "ts-node ./scripts/format/format.ts" @@ -122,7 +122,7 @@ "karma-sourcemap-loader": "0.4.0", "karma-spec-reporter": "0.0.34", "karma-summary-reporter": "3.1.1", - "karma-webpack": "4.0.2", + "karma-webpack": "5.0.0", "lcov-result-merger": "3.1.0", "lerna": "4.0.0", "listr": "0.14.3", @@ -132,9 +132,12 @@ "mkdirp": "1.0.4", "mocha": "9.2.2", "mz": "2.7.0", + "node-polyfill-webpack-plugin": "2.0.1", "npm-run-all": "4.1.5", "nyc": "15.1.0", "ora": "5.4.1", + "patch-package": "7.0.0", + "postinstall-postinstall": "2.1.0", "prettier": "2.8.7", "protractor": "5.4.2", "request": "2.88.2", @@ -151,7 +154,7 @@ "typedoc": "0.16.11", "typescript": "4.7.4", "watch": "1.0.2", - "webpack": "4.46.0", + "webpack": "5.76.0", "yargs": "17.7.1" } } diff --git a/packages/auth-compat/test/helpers/helpers.ts b/packages/auth-compat/test/helpers/helpers.ts index e689b77eb67..0a3579fef6b 100644 --- a/packages/auth-compat/test/helpers/helpers.ts +++ b/packages/auth-compat/test/helpers/helpers.ts @@ -17,6 +17,7 @@ import * as sinon from 'sinon'; import firebase from '@firebase/app-compat'; +import '@firebase/auth-compat'; import { Provider } from '@firebase/component'; import '../..'; diff --git a/packages/auth/test/helpers/integration/helpers.ts b/packages/auth/test/helpers/integration/helpers.ts index 0ac5b3b4428..9825a8f4ba0 100644 --- a/packages/auth/test/helpers/integration/helpers.ts +++ b/packages/auth/test/helpers/integration/helpers.ts @@ -17,7 +17,7 @@ import * as sinon from 'sinon'; import { deleteApp, initializeApp } from '@firebase/app'; -import { Auth, User } from '../../../src/model/public_types'; +import { Auth, User } from '@firebase/auth'; import { getAuth, connectAuthEmulator } from '../../../'; // Use browser OR node dist entrypoint depending on test env. import { _generateEventId } from '../../../src/core/util/event_id'; diff --git a/patches/karma-webpack+5.0.0.patch b/patches/karma-webpack+5.0.0.patch new file mode 100644 index 00000000000..3e3424a2912 --- /dev/null +++ b/patches/karma-webpack+5.0.0.patch @@ -0,0 +1,23 @@ +diff --git a/node_modules/karma-webpack/lib/webpack/plugin.js b/node_modules/karma-webpack/lib/webpack/plugin.js +index 47b993c..3b75a9e 100644 +--- a/node_modules/karma-webpack/lib/webpack/plugin.js ++++ b/node_modules/karma-webpack/lib/webpack/plugin.js +@@ -1,4 +1,5 @@ + const fs = require('fs'); ++const path = require('path'); + + class KW_WebpackPlugin { + constructor(options) { +@@ -14,9 +15,10 @@ class KW_WebpackPlugin { + // read generated file content and store for karma preprocessor + this.controller.bundlesContent = {}; + stats.toJson().assets.forEach((webpackFileObj) => { +- const filePath = `${compiler.options.output.path}/${ ++ const filePath = path.resolve( ++ compiler.options.output.path, + webpackFileObj.name +- }`; ++ ); + this.controller.bundlesContent[webpackFileObj.name] = fs.readFileSync( + filePath, + 'utf-8' diff --git a/repo-scripts/size-analysis/package.json b/repo-scripts/size-analysis/package.json index 90a8fb4257f..db2c220793f 100644 --- a/repo-scripts/size-analysis/package.json +++ b/repo-scripts/size-analysis/package.json @@ -26,7 +26,7 @@ "rollup-plugin-replace": "2.2.0", "rollup-plugin-typescript2": "0.31.2", "@rollup/plugin-virtual": "2.1.0", - "webpack": "4.46.0", + "webpack": "5.76.0", "@types/webpack": "5.28.0", "webpack-virtual-modules": "0.5.0", "child-process-promise": "2.2.1", diff --git a/yarn.lock b/yarn.lock index 4ff933dde34..a7470476ef8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3594,6 +3594,14 @@ "@types/eslint" "*" "@types/estree" "*" +"@types/eslint-scope@^3.7.3": + version "3.7.4" + resolved "https://registry.npmjs.org/@types/eslint-scope/-/eslint-scope-3.7.4.tgz#37fc1223f0786c39627068a12e94d6e6fc61de16" + integrity sha512-9K4zoImiZc3HlIp6AVUDE4CWYx22a+lhSZMYNpbjW04+YF0KWj4pJXnEMjdnFTiQibFFmElcsasJXDbdI/EPhA== + dependencies: + "@types/eslint" "*" + "@types/estree" "*" + "@types/eslint@*", "@types/eslint@7.29.0": version "7.29.0" resolved "https://registry.npmjs.org/@types/eslint/-/eslint-7.29.0.tgz#e56ddc8e542815272720bb0b4ccc2aff9c3e1c78" @@ -3612,6 +3620,11 @@ resolved "https://registry.npmjs.org/@types/estree/-/estree-0.0.39.tgz#e177e699ee1b8c22d23174caaa7422644389509f" integrity sha512-EYNwp3bU+98cpU4lAWYYL7Zz+2gryWH1qbdDTidVd6hkiR6weksdbMadyXKXNPEkQFhXM+hVO9ZygomHXp+AIw== +"@types/estree@^0.0.51": + version "0.0.51" + resolved "https://registry.npmjs.org/@types/estree/-/estree-0.0.51.tgz#cfd70924a25a3fd32b218e5e420e6897e1ac4f40" + integrity sha512-CuPgU6f3eT/XgKKPqKd/gLZV1Xmvf1a2R5POBOGQa6uv82xpls89HU5zKeVoyR8XzHd1RGNOlQlvUe3CFkjWNQ== + "@types/expect@^1.20.4": version "1.20.4" resolved "https://registry.npmjs.org/@types/expect/-/expect-1.20.4.tgz#8288e51737bf7e3ab5d7c77bfa695883745264e5" @@ -4367,6 +4380,11 @@ tslib "^2" upath2 "^3.1.13" +"@yarnpkg/lockfile@^1.1.0": + version "1.1.0" + resolved "https://registry.npmjs.org/@yarnpkg/lockfile/-/lockfile-1.1.0.tgz#e77a97fbd345b76d83245edcd17d393b1b41fb31" + integrity sha512-GpSwvyXOcOOlV70vbnzjj4fW5xW/FdUF6nQEt1ENy7m4ZCczi1+/buVUPAqmGfqznsORNFzUMjctTIp8a9tuCQ== + JSONStream@^1.0.4: version "1.3.5" resolved "https://registry.npmjs.org/JSONStream/-/JSONStream-1.3.5.tgz#3208c1f08d3a4d99261ab64f92302bc15e111ca0" @@ -4448,6 +4466,11 @@ acorn@^8.7.0: resolved "https://registry.npmjs.org/acorn/-/acorn-8.8.2.tgz#1b2f25db02af965399b9776b0c2c391276d37c4a" integrity sha512-xjIYgE8HBrkpd/sJqOGNspf8uHG+NOHGOw6a/Urj8taM2EXfdNAH2oFcPeIFfsv3+kz/mJrS5VuMqbNLjCa2vw== +acorn@^8.7.1: + version "8.9.0" + resolved "https://registry.npmjs.org/acorn/-/acorn-8.9.0.tgz#78a16e3b2bcc198c10822786fa6679e245db5b59" + integrity sha512-jaVNAFBHNLXspO543WnNNPZFRtavh3skAkITqD0/2aeMkKZTN+254PyhwxFYrk3vQ1xfY+2wbesJMs/JC8/PwQ== + add-stream@^1.0.0: version "1.0.0" resolved "https://registry.npmjs.org/add-stream/-/add-stream-1.0.0.tgz#6a7990437ca736d5e1288db92bd3266d5f5cb2aa" @@ -4579,11 +4602,6 @@ ansi-colors@^1.0.1: dependencies: ansi-wrap "^0.1.0" -ansi-colors@^3.0.0: - version "3.2.4" - resolved "https://registry.npmjs.org/ansi-colors/-/ansi-colors-3.2.4.tgz#e3a3da4bfbae6c86a9c285625de124a234026fbf" - integrity sha512-hHUXGagefjN2iRrID63xckIvotOXOojhQKWIPUZ4mNUZ9nLZW+7FMNoE1lOkEhNWYsx/7ysGIuJYCiMAA9FnrA== - ansi-colors@^4.1.3: version "4.1.3" resolved "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz#37611340eb2243e70cc604cad35d63270d48781b" @@ -5713,6 +5731,14 @@ buffer@^5.4.3, buffer@^5.5.0: base64-js "^1.3.1" ieee754 "^1.1.13" +buffer@^6.0.3: + version "6.0.3" + resolved "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz#2ace578459cc8fbe2a70aaa8f52ee63b6a74c6c6" + integrity sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA== + dependencies: + base64-js "^1.3.1" + ieee754 "^1.2.1" + buffers@~0.1.1: version "0.1.1" resolved "https://registry.npmjs.org/buffers/-/buffers-0.1.1.tgz#b24579c3bed4d6d396aeee6d9a8ae7f5482ab7bb" @@ -6113,6 +6139,11 @@ ci-info@^3.1.1: resolved "https://registry.npmjs.org/ci-info/-/ci-info-3.2.0.tgz#2876cb948a498797b5236f0095bc057d0dca38b6" integrity sha512-dVqRX7fLUm8J6FgHJ418XuIgDLZDkYcDFTeL6TA2gt5WlIZUQrrH6EZrNClwT/H0FateUsZkGIOPRrLbP+PR9A== +ci-info@^3.7.0: + version "3.8.0" + resolved "https://registry.npmjs.org/ci-info/-/ci-info-3.8.0.tgz#81408265a5380c929f0bc665d62256628ce9ef91" + integrity sha512-eXTggHWSooYhq49F2opQhuHWgzucfF2YgODK4e1566GQs5BIfP30B0oenwBJHfWxAs2fyPB1s7Mg949zLf61Yw== + cipher-base@^1.0.0, cipher-base@^1.0.1, cipher-base@^1.0.3: version "1.0.4" resolved "https://registry.npmjs.org/cipher-base/-/cipher-base-1.0.4.tgz#8760e4ecc272f4c363532f926d874aae2c1397de" @@ -7382,7 +7413,7 @@ domain-browser@^1.1.1: resolved "https://registry.npmjs.org/domain-browser/-/domain-browser-1.2.0.tgz#3d31f50191a6749dd1375a7f522e823d42e54eda" integrity sha512-jnjyiM6eRyZl2H+W8Q/zLMA481hzi0eszAaBUzIVnmYVDBbnLxVNnfu1HgEBvCbL+71FrxMl3E6lpKH7Ge3OXA== -domain-browser@^4.16.0: +domain-browser@^4.16.0, domain-browser@^4.22.0: version "4.22.0" resolved "https://registry.npmjs.org/domain-browser/-/domain-browser-4.22.0.tgz#6ddd34220ec281f9a65d3386d267ddd35c491f9f" integrity sha512-IGBwjF7tNk3cwypFNH/7bfzBcgSCbaMOD3GsaY1AU/JRrnHnYgEM0+9kQt52iZxjNsjBtJYtao146V+f8jFZNw== @@ -7570,6 +7601,14 @@ enhanced-resolve@^4.0.0, enhanced-resolve@^4.5.0: memory-fs "^0.5.0" tapable "^1.0.0" +enhanced-resolve@^5.10.0: + version "5.15.0" + resolved "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.15.0.tgz#1af946c7d93603eb88e9896cee4904dc012e9c35" + integrity sha512-LXYT42KJ7lpIKECr2mAXIaMldcNCh/7E0KBKOu4KSfkHmP+mZmSs+8V5gBAqisWBy0OO4W5Oyys0GO1Y8KtdKg== + dependencies: + graceful-fs "^4.2.4" + tapable "^2.2.0" + enhanced-resolve@^5.8.0: version "5.8.3" resolved "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.8.3.tgz#6d552d465cce0423f5b3d718511ea53826a7b2f0" @@ -7677,6 +7716,11 @@ es-module-lexer@^0.7.1: resolved "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-0.7.1.tgz#c2c8e0f46f2df06274cdaf0dd3f3b33e0a0b267d" integrity sha512-MgtWFl5No+4S3TmhDmCz2ObFGm6lEpTnzbQi+Dd+pw4mlTIZTmM2iAs5gRlmx5zS9luzobCSBSI90JM/1/JgOw== +es-module-lexer@^0.9.0: + version "0.9.3" + resolved "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-0.9.3.tgz#6f13db00cc38417137daf74366f535c8eb438f19" + integrity sha512-1HQ2M2sPtxwnvOvT1ZClHyQDiggdNjURWpY2we6aMKCQiUVxTmVs2UYPLIrD84sS+kMdUwfBSylbJPwNnBrnHQ== + es-shim-unscopables@^1.0.0: version "1.0.0" resolved "https://registry.npmjs.org/es-shim-unscopables/-/es-shim-unscopables-1.0.0.tgz#702e632193201e3edf8713635d083d378e510241" @@ -8017,7 +8061,7 @@ events-listener@^1.1.0: resolved "https://registry.npmjs.org/events-listener/-/events-listener-1.1.0.tgz#dd49b4628480eba58fde31b870ee346b3990b349" integrity sha512-Kd3EgYfODHueq6GzVfs/VUolh2EgJsS8hkO3KpnDrxVjU3eq63eXM2ujXkhPP+OkeUOhL8CxdfZbQXzryb5C4g== -events@^3.0.0, events@^3.2.0: +events@^3.0.0, events@^3.2.0, events@^3.3.0: version "3.3.0" resolved "https://registry.npmjs.org/events/-/events-3.3.0.tgz#31a95ad0a924e2d2c419a813aeb2c4e878ea7400" integrity sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q== @@ -8456,6 +8500,11 @@ filter-obj@^1.1.0: resolved "https://registry.npmjs.org/filter-obj/-/filter-obj-1.1.0.tgz#9b311112bc6c6127a16e016c6c5d7f19e0805c5b" integrity sha1-mzERErxsYSehbgFsbF1/GeCAXFs= +filter-obj@^2.0.2: + version "2.0.2" + resolved "https://registry.npmjs.org/filter-obj/-/filter-obj-2.0.2.tgz#fff662368e505d69826abb113f0f6a98f56e9d5f" + integrity sha512-lO3ttPjHZRfjMcxWKb1j1eDhTFsu4meeR3lnMcnBFhk6RuLhvEiuALu2TlfL310ph4lCYYwgF/ElIjdP739tdg== + finalhandler@1.1.2, finalhandler@~1.1.2: version "1.1.2" resolved "https://registry.npmjs.org/finalhandler/-/finalhandler-1.1.2.tgz#b7e7d000ffd11938d0fdb053506f6ebabe9f587d" @@ -8556,6 +8605,13 @@ find-yarn-workspace-root2@1.2.16: micromatch "^4.0.2" pkg-dir "^4.2.0" +find-yarn-workspace-root@^2.0.0: + version "2.0.0" + resolved "https://registry.npmjs.org/find-yarn-workspace-root/-/find-yarn-workspace-root-2.0.0.tgz#f47fb8d239c900eb78179aa81b66673eac88f7bd" + integrity sha512-1IMnbjt4KzsQfnhnzNd8wUEgXZ44IzZaZmnLYx7D5FZlaHt2gW20Cri8Q+E/t5tIj4+epTBub+2Zxu/vNILzqQ== + dependencies: + micromatch "^4.0.2" + findup-sync@^2.0.0: version "2.0.0" resolved "https://registry.npmjs.org/findup-sync/-/findup-sync-2.0.0.tgz#9326b1488c22d1a6088650a86901b2d9a90a2cbc" @@ -8851,7 +8907,7 @@ fs-extra@^8.1.0: jsonfile "^4.0.0" universalify "^0.1.0" -fs-extra@^9.1.0: +fs-extra@^9.0.0, fs-extra@^9.1.0: version "9.1.0" resolved "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz#5954460c764a8da2094ba3554bf839e6b9a7c86d" integrity sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ== @@ -9526,7 +9582,7 @@ graceful-fs@^4.1.6, graceful-fs@^4.2.0: resolved "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.9.tgz#041b05df45755e587a24942279b9d113146e1c96" integrity sha512-NtNxqUcXgpW2iMrfqSfR73Glt39K+BLwWsPs94yR63v45T0Wbej7eRmL5cWfwEgqXnmjQp3zaJTshdRW/qC2ZQ== -graceful-fs@^4.2.10: +graceful-fs@^4.2.10, graceful-fs@^4.2.9: version "4.2.11" resolved "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz#4183e4e8bf08bb6e05bbb2f7d2e0c8f712ca40e3" integrity sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ== @@ -10012,7 +10068,7 @@ idb@7.1.1: resolved "https://registry.npmjs.org/idb/-/idb-7.1.1.tgz#d910ded866d32c7ced9befc5bfdf36f572ced72b" integrity sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ== -ieee754@^1.1.13, ieee754@^1.1.4: +ieee754@^1.1.13, ieee754@^1.1.4, ieee754@^1.2.1: version "1.2.1" resolved "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz#8eb7a10a63fff25d15a57b001586d177d1b0d352" integrity sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA== @@ -10795,7 +10851,7 @@ is-wsl@^1.1.0: resolved "https://registry.npmjs.org/is-wsl/-/is-wsl-1.1.0.tgz#1f16e4aa22b04d1336b66188a66af3c600c3a66d" integrity sha1-HxbkqiKwTRM2tmGIpmrzxgDDpm0= -is-wsl@^2.2.0: +is-wsl@^2.1.1, is-wsl@^2.2.0: version "2.2.0" resolved "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz#74a4c76e77ca9fd3f932f290c17ea326cd157271" integrity sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww== @@ -11256,7 +11312,7 @@ json-parse-better-errors@^1.0.1, json-parse-better-errors@^1.0.2: resolved "https://registry.npmjs.org/json-parse-better-errors/-/json-parse-better-errors-1.0.2.tgz#bb867cfb3450e69107c131d1c514bab3dc8bcaa9" integrity sha512-mrqyZKfX5EhL7hvqcV6WG1yYjnjeuYDzDhhcAAUrq8Po85NBQBJP+ZDUT75qZQ98IkUoBqdkExkukOU7Ts2wrw== -json-parse-even-better-errors@^2.3.0: +json-parse-even-better-errors@^2.3.0, json-parse-even-better-errors@^2.3.1: version "2.3.1" resolved "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz#7c47805a94319928e05777405dc12e1f7a4ee02d" integrity sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w== @@ -11573,17 +11629,14 @@ karma-typescript@5.5.4: util "^0.12.1" vm-browserify "^1.1.2" -karma-webpack@4.0.2: - version "4.0.2" - resolved "https://registry.npmjs.org/karma-webpack/-/karma-webpack-4.0.2.tgz#23219bd95bdda853e3073d3874d34447c77bced0" - integrity sha512-970/okAsdUOmiMOCY8sb17A2I8neS25Ad9uhyK3GHgmRSIFJbDcNEFE8dqqUhNe9OHiCC9k3DMrSmtd/0ymP1A== +karma-webpack@5.0.0: + version "5.0.0" + resolved "https://registry.npmjs.org/karma-webpack/-/karma-webpack-5.0.0.tgz#2a2c7b80163fe7ffd1010f83f5507f95ef39f840" + integrity sha512-+54i/cd3/piZuP3dr54+NcFeKOPnys5QeM1IY+0SPASwrtHsliXUiCL50iW+K9WWA7RvamC4macvvQ86l3KtaA== dependencies: - clone-deep "^4.0.1" - loader-utils "^1.1.0" - neo-async "^2.6.1" - schema-utils "^1.0.0" - source-map "^0.7.3" - webpack-dev-middleware "^3.7.0" + glob "^7.1.3" + minimatch "^3.0.4" + webpack-merge "^4.1.5" karma@6.4.2: version "6.4.2" @@ -11646,6 +11699,13 @@ kind-of@^6.0.0, kind-of@^6.0.2, kind-of@^6.0.3: resolved "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz#07c05034a6c349fa06e24fa35aa76db4580ce4dd" integrity sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw== +klaw-sync@^6.0.0: + version "6.0.0" + resolved "https://registry.npmjs.org/klaw-sync/-/klaw-sync-6.0.0.tgz#1fd2cfd56ebb6250181114f0a581167099c2b28c" + integrity sha512-nIeuVSzdCCs6TDPTqI8w1Yre34sSq7AkZ4B3sfOBbI2CgVSB4Du4aLQijFU2+lhAFCwt9+42Hel6lQNIv6AntQ== + dependencies: + graceful-fs "^4.1.11" + kleur@^4.1.4: version "4.1.5" resolved "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz#95106101795f7050c6c650f350c683febddb1780" @@ -12590,7 +12650,7 @@ mime@1.6.0, mime@^1.6.0: resolved "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz#32cd9e5c64553bd58d19a568af452acff04981b1" integrity sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg== -mime@^2.4.4, mime@^2.5.2: +mime@^2.5.2: version "2.5.2" resolved "https://registry.npmjs.org/mime/-/mime-2.5.2.tgz#6e3dc6cc2b9510643830e5f19d5cb753da5eeabe" integrity sha512-tqkh47FzKeCPD2PUiPB6pkbMzsCasjxAfC62/Wap5qrUWcb+sFasXUC5I3gYM5iBM8v/Qpn4UK0x+j0iHyFPDg== @@ -13239,6 +13299,37 @@ node-modules-regexp@^1.0.0: resolved "https://registry.npmjs.org/node-modules-regexp/-/node-modules-regexp-1.0.0.tgz#8d9dbe28964a4ac5712e9131642107c71e90ec40" integrity sha1-jZ2+KJZKSsVxLpExZCEHxx6Q7EA= +node-polyfill-webpack-plugin@2.0.1: + version "2.0.1" + resolved "https://registry.npmjs.org/node-polyfill-webpack-plugin/-/node-polyfill-webpack-plugin-2.0.1.tgz#141d86f177103a8517c71d99b7c6a46edbb1bb58" + integrity sha512-ZUMiCnZkP1LF0Th2caY6J/eKKoA0TefpoVa68m/LQU1I/mE8rGt4fNYGgNuCcK+aG8P8P43nbeJ2RqJMOL/Y1A== + dependencies: + assert "^2.0.0" + browserify-zlib "^0.2.0" + buffer "^6.0.3" + console-browserify "^1.2.0" + constants-browserify "^1.0.0" + crypto-browserify "^3.12.0" + domain-browser "^4.22.0" + events "^3.3.0" + filter-obj "^2.0.2" + https-browserify "^1.0.0" + os-browserify "^0.3.0" + path-browserify "^1.0.1" + process "^0.11.10" + punycode "^2.1.1" + querystring-es3 "^0.2.1" + readable-stream "^4.0.0" + stream-browserify "^3.0.0" + stream-http "^3.2.0" + string_decoder "^1.3.0" + timers-browserify "^2.0.12" + tty-browserify "^0.0.1" + type-fest "^2.14.0" + url "^0.11.0" + util "^0.12.4" + vm-browserify "^1.1.2" + node-pre-gyp@^0.11.0: version "0.11.0" resolved "https://registry.npmjs.org/node-pre-gyp/-/node-pre-gyp-0.11.0.tgz#db1f33215272f692cd38f03238e3e9b47c5dd054" @@ -13716,6 +13807,14 @@ open@^6.3.0: dependencies: is-wsl "^1.1.0" +open@^7.4.2: + version "7.4.2" + resolved "https://registry.npmjs.org/open/-/open-7.4.2.tgz#b8147e26dcf3e426316c730089fd71edd29c2321" + integrity sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q== + dependencies: + is-docker "^2.0.0" + is-wsl "^2.1.1" + openapi3-ts@^2.0.1: version "2.0.1" resolved "https://registry.npmjs.org/openapi3-ts/-/openapi3-ts-2.0.1.tgz#b270aecea09e924f1886bc02a72608fca5a98d85" @@ -14131,12 +14230,32 @@ pascalcase@^0.1.1: resolved "https://registry.npmjs.org/pascalcase/-/pascalcase-0.1.1.tgz#b363e55e8006ca6fe21784d2db22bd15d7917f14" integrity sha1-s2PlXoAGym/iF4TS2yK9FdeRfxQ= +patch-package@7.0.0: + version "7.0.0" + resolved "https://registry.npmjs.org/patch-package/-/patch-package-7.0.0.tgz#5c646b6b4b4bf37e5184a6950777b21dea6bb66e" + integrity sha512-eYunHbnnB2ghjTNc5iL1Uo7TsGMuXk0vibX3RFcE/CdVdXzmdbMsG/4K4IgoSuIkLTI5oHrMQk4+NkFqSed0BQ== + dependencies: + "@yarnpkg/lockfile" "^1.1.0" + chalk "^4.1.2" + ci-info "^3.7.0" + cross-spawn "^7.0.3" + find-yarn-workspace-root "^2.0.0" + fs-extra "^9.0.0" + klaw-sync "^6.0.0" + minimist "^1.2.6" + open "^7.4.2" + rimraf "^2.6.3" + semver "^5.6.0" + slash "^2.0.0" + tmp "^0.0.33" + yaml "^2.2.2" + path-browserify@0.0.1: version "0.0.1" resolved "https://registry.npmjs.org/path-browserify/-/path-browserify-0.0.1.tgz#e6c4ddd7ed3aa27c68a20cc4e50e1a4ee83bbc4a" integrity sha512-BapA40NHICOS+USX9SN4tyhq+A2RrN/Ws5F0Z5aMHDp98Fl86lX8Oti8B7uN93L4Ifv4fHOEA+pQw87gmMO/lQ== -path-browserify@^1.0.0: +path-browserify@^1.0.0, path-browserify@^1.0.1: version "1.0.1" resolved "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz#d98454a9c3753d5790860f16f68867b9e46be1fd" integrity sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g== @@ -14395,6 +14514,11 @@ postcss@^7.0.16: source-map "^0.6.1" supports-color "^6.1.0" +postinstall-postinstall@2.1.0: + version "2.1.0" + resolved "https://registry.npmjs.org/postinstall-postinstall/-/postinstall-postinstall-2.1.0.tgz#4f7f77441ef539d1512c40bd04c71b06a4704ca3" + integrity sha512-7hQX6ZlZXIoRiWNrbMQaLzUUfH+sSx39u8EJ9HYuDc1kLo9IXKWjM5RSquZN1ad5GnH8CGFM78fsAAQi3OKEEQ== + preferred-pm@^3.0.0: version "3.0.3" resolved "https://registry.npmjs.org/preferred-pm/-/preferred-pm-3.0.3.tgz#1b6338000371e3edbce52ef2e4f65eb2e73586d6" @@ -14975,6 +15099,16 @@ readable-stream@1.1.x: string_decoder "^1.1.1" util-deprecate "^1.0.1" +readable-stream@^4.0.0: + version "4.4.0" + resolved "https://registry.npmjs.org/readable-stream/-/readable-stream-4.4.0.tgz#55ce132d60a988c460d75c631e9ccf6a7229b468" + integrity sha512-kDMOq0qLtxV9f/SQv522h8cxZBqNZXuXNyjyezmfAAuribMyVXziljpQ/uQhfE1XLg2/TLTW2DsnoE4VAi/krg== + dependencies: + abort-controller "^3.0.0" + buffer "^6.0.3" + events "^3.3.0" + process "^0.11.10" + readdir-glob@^1.0.0: version "1.1.1" resolved "https://registry.npmjs.org/readdir-glob/-/readdir-glob-1.1.1.tgz#f0e10bb7bf7bfa7e0add8baffdc54c3f7dbee6c4" @@ -15992,6 +16126,11 @@ sinon@9.2.4: nise "^4.0.4" supports-color "^7.1.0" +slash@^2.0.0: + version "2.0.0" + resolved "https://registry.npmjs.org/slash/-/slash-2.0.0.tgz#de552851a1759df3a8f206535442f5ec4ddeab44" + integrity sha512-ZYKh3Wh2z1PpEXWr0MpSBZ0V6mZHAQfYevttO11c51CaWjGTaadiKZ+wVt1PbMlDV5qhMFslpZCemhwOK7C89A== + slash@^3.0.0: version "3.0.0" resolved "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz#6539be870c165adbd5240220dbe361f1bc4d4634" @@ -16466,7 +16605,7 @@ stream-http@^2.7.2: to-arraybuffer "^1.0.0" xtend "^4.0.0" -stream-http@^3.1.0: +stream-http@^3.1.0, stream-http@^3.2.0: version "3.2.0" resolved "https://registry.npmjs.org/stream-http/-/stream-http-3.2.0.tgz#1872dfcf24cb15752677e40e5c3f9cc1926028b5" integrity sha512-Oq1bLqisTyK3TSCXpPbT4sdeYNdmyZJv1LxpEm2vu1ZhK89kSE5YXwZc3cWk0MagGaKriBh9mCFbVGtO+vY29A== @@ -17044,7 +17183,7 @@ time-stamp@^1.0.0: resolved "https://registry.npmjs.org/time-stamp/-/time-stamp-1.1.0.tgz#764a5a11af50561921b133f3b44e618687e0f5c3" integrity sha1-dkpaEa9QVhkhsTPztE5hhofg9cM= -timers-browserify@^2.0.11, timers-browserify@^2.0.4: +timers-browserify@^2.0.11, timers-browserify@^2.0.12, timers-browserify@^2.0.4: version "2.0.12" resolved "https://registry.npmjs.org/timers-browserify/-/timers-browserify-2.0.12.tgz#44a45c11fbf407f34f97bccd1577c652361b00ee" integrity sha512-9phl76Cqm6FhSX9Xe1ZUAMLtm1BLkKj2Qd5ApyWkXzsMRaA7dgr81kf4wJmQf/hAvg8EEyJxDo3du/0KlhPiKQ== @@ -17420,6 +17559,11 @@ type-fest@^1.0.2: resolved "https://registry.npmjs.org/type-fest/-/type-fest-1.4.0.tgz#e9fb813fe3bf1744ec359d55d1affefa76f14be1" integrity sha512-yGSza74xk0UG8k+pLh5oeoYirvIiWo5t0/o3zHHAO2tRDiZcxWP7fywNlXhqb6/r6sWvwi+RsyQMWhVLe4BVuA== +type-fest@^2.14.0: + version "2.19.0" + resolved "https://registry.npmjs.org/type-fest/-/type-fest-2.19.0.tgz#88068015bb33036a598b952e55e9311a60fd3a9b" + integrity sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA== + type-is@~1.6.17, type-is@~1.6.18: version "1.6.18" resolved "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz#4e552cd05df09467dcbc4ef739de89f2cf37c131" @@ -17840,6 +17984,17 @@ util@^0.12.0, util@^0.12.1: safe-buffer "^5.1.2" which-typed-array "^1.1.2" +util@^0.12.4: + version "0.12.5" + resolved "https://registry.npmjs.org/util/-/util-0.12.5.tgz#5f17a6059b73db61a875668781a1c2b136bd6fbc" + integrity sha512-kZf/K6hEIrWHI6XqOFUiiMa+79wE/D8Q+NCNAWclkyg3b4d2k7s0QGepNjiABc+aR3N1PAyHL7p6UcLY6LmrnA== + dependencies: + inherits "^2.0.3" + is-arguments "^1.0.4" + is-generator-function "^1.0.7" + is-typed-array "^1.1.3" + which-typed-array "^1.1.2" + utils-merge@1.0.1: version "1.0.1" resolved "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz#9f95710f50a267947b2ccc124741c1028427e713" @@ -18035,6 +18190,14 @@ watchpack@^2.2.0: glob-to-regexp "^0.4.1" graceful-fs "^4.1.2" +watchpack@^2.4.0: + version "2.4.0" + resolved "https://registry.npmjs.org/watchpack/-/watchpack-2.4.0.tgz#fa33032374962c78113f93c7f2fb4c54c9862a5d" + integrity sha512-Lcvm7MGST/4fup+ifyKi2hjyIAwcdI4HRgtvTpIUxBRhB+RFtUh8XtDOxUfctVCnhVi+QQj49i91OyvzkJl6cg== + dependencies: + glob-to-regexp "^0.4.1" + graceful-fs "^4.1.2" + wcwidth@^1.0.0, wcwidth@^1.0.1: version "1.0.1" resolved "https://registry.npmjs.org/wcwidth/-/wcwidth-1.0.1.tgz#f0b0dcf915bc5ff1528afadb2c0e17b532da2fe8" @@ -18082,24 +18245,12 @@ webidl-conversions@^7.0.0: resolved "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz#256b4e1882be7debbf01d05f0aa2039778ea080a" integrity sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g== -webpack-dev-middleware@^3.7.0: - version "3.7.3" - resolved "https://registry.npmjs.org/webpack-dev-middleware/-/webpack-dev-middleware-3.7.3.tgz#0639372b143262e2b84ab95d3b91a7597061c2c5" - integrity sha512-djelc/zGiz9nZj/U7PTBi2ViorGJXEWo/3ltkPbDyxCXhhEXkW0ce99falaok4TPj+AsxLiXJR0EBOb0zh9fKQ== - dependencies: - memory-fs "^0.4.1" - mime "^2.4.4" - mkdirp "^0.5.1" - range-parser "^1.2.1" - webpack-log "^2.0.0" - -webpack-log@^2.0.0: - version "2.0.0" - resolved "https://registry.npmjs.org/webpack-log/-/webpack-log-2.0.0.tgz#5b7928e0637593f119d32f6227c1e0ac31e1b47f" - integrity sha512-cX8G2vR/85UYG59FgkoMamwHUIkSSlV3bBMRsbxVXVUk2j6NleCKjQ/WE9eYg9WY4w25O9w8wKP4rzNZFmUcUg== +webpack-merge@^4.1.5: + version "4.2.2" + resolved "https://registry.npmjs.org/webpack-merge/-/webpack-merge-4.2.2.tgz#a27c52ea783d1398afd2087f547d7b9d2f43634d" + integrity sha512-TUE1UGoTX2Cd42j3krGYqObZbOD+xF7u28WB7tfUordytSjbWTIjK/8V0amkBfTYN4/pB/GIDlJZZ657BGG19g== dependencies: - ansi-colors "^3.0.0" - uuid "^3.3.2" + lodash "^4.17.15" webpack-sources@^1.4.0, webpack-sources@^1.4.1: version "1.4.3" @@ -18114,6 +18265,11 @@ webpack-sources@^3.2.0: resolved "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.2.1.tgz#251a7d9720d75ada1469ca07dbb62f3641a05b6d" integrity sha512-t6BMVLQ0AkjBOoRTZgqrWm7xbXMBzD+XDq2EZ96+vMfn3qKgsvdXZhbPZ4ElUOpdv4u+iiGe+w3+J75iy/bYGA== +webpack-sources@^3.2.3: + version "3.2.3" + resolved "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.2.3.tgz#2d4daab8451fd4b240cc27055ff6a0c2ccea0cde" + integrity sha512-/DyMEOrDgLKKIG0fmvtz+4dUX/3Ghozwgm6iPp8KRhvn+eQf9+Q7GWxVNMk3+uCPWfdXYC4ExGBckIXdFEfH1w== + webpack-stream@6.1.2: version "6.1.2" resolved "https://registry.npmjs.org/webpack-stream/-/webpack-stream-6.1.2.tgz#ee90bc07d0ff937239d75ed22aa728072c9e7ee1" @@ -18134,7 +18290,37 @@ webpack-virtual-modules@0.5.0: resolved "https://registry.npmjs.org/webpack-virtual-modules/-/webpack-virtual-modules-0.5.0.tgz#362f14738a56dae107937ab98ea7062e8bdd3b6c" integrity sha512-kyDivFZ7ZM0BVOUteVbDFhlRt7Ah/CSPwJdi8hBpkK7QLumUqdLtVfm/PX/hkcnrvr0i77fO5+TjZ94Pe+C9iw== -webpack@4.46.0, webpack@^4.26.1: +webpack@5.76.0: + version "5.76.0" + resolved "https://registry.npmjs.org/webpack/-/webpack-5.76.0.tgz#f9fb9fb8c4a7dbdcd0d56a98e56b8a942ee2692c" + integrity sha512-l5sOdYBDunyf72HW8dF23rFtWq/7Zgvt/9ftMof71E/yUb1YLOBmTgA2K4vQthB3kotMrSj609txVE0dnr2fjA== + dependencies: + "@types/eslint-scope" "^3.7.3" + "@types/estree" "^0.0.51" + "@webassemblyjs/ast" "1.11.1" + "@webassemblyjs/wasm-edit" "1.11.1" + "@webassemblyjs/wasm-parser" "1.11.1" + acorn "^8.7.1" + acorn-import-assertions "^1.7.6" + browserslist "^4.14.5" + chrome-trace-event "^1.0.2" + enhanced-resolve "^5.10.0" + es-module-lexer "^0.9.0" + eslint-scope "5.1.1" + events "^3.2.0" + glob-to-regexp "^0.4.1" + graceful-fs "^4.2.9" + json-parse-even-better-errors "^2.3.1" + loader-runner "^4.2.0" + mime-types "^2.1.27" + neo-async "^2.6.2" + schema-utils "^3.1.0" + tapable "^2.1.1" + terser-webpack-plugin "^5.1.3" + watchpack "^2.4.0" + webpack-sources "^3.2.3" + +webpack@^4.26.1: version "4.46.0" resolved "https://registry.npmjs.org/webpack/-/webpack-4.46.0.tgz#bf9b4404ea20a073605e0a011d188d77cb6ad542" integrity sha512-6jJuJjg8znb/xRItk7bkT0+Q7AHCYjjFnvKIWQPkNIOyRqoCGvkOs0ipeQzrqz4l5FtN5ZI/ukEHroeX/o1/5Q== @@ -18569,6 +18755,11 @@ yaml@^1.10.0: resolved "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz#2301c5ffbf12b467de8da2333a459e29e7920e4b" integrity sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg== +yaml@^2.2.2: + version "2.3.1" + resolved "https://registry.npmjs.org/yaml/-/yaml-2.3.1.tgz#02fe0975d23cd441242aa7204e09fc28ac2ac33b" + integrity sha512-2eHWfjaoXgTBC2jNM1LRef62VQa0umtvRiDSk6HSzW7RvS5YtkabJrwYLLEKWBc8a5U2PTSCs+dJjUTJdlHsWQ== + yargs-parser@20.2.4: version "20.2.4" resolved "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.4.tgz#b42890f14566796f85ae8e3a25290d205f154a54" From 5dac8b37a974309398317c5231ca6a41af2a48a5 Mon Sep 17 00:00:00 2001 From: omochimetaru Date: Tue, 15 Aug 2023 07:04:18 +0900 Subject: [PATCH 14/15] [Auth] Fix auth event uncancellable bug (#7498) * Fix auth event uncancellable bug * add changeset file * includes issue number on changesets * yarn format --- .changeset/mean-candles-approve.md | 5 +++++ packages/auth/src/core/auth/auth_impl.ts | 25 +++++++++++++++++++++--- 2 files changed, 27 insertions(+), 3 deletions(-) create mode 100644 .changeset/mean-candles-approve.md diff --git a/.changeset/mean-candles-approve.md b/.changeset/mean-candles-approve.md new file mode 100644 index 00000000000..ff85758bc22 --- /dev/null +++ b/.changeset/mean-candles-approve.md @@ -0,0 +1,5 @@ +--- +'@firebase/auth': patch +--- + +Fix auth event uncancellable bug #7383 diff --git a/packages/auth/src/core/auth/auth_impl.ts b/packages/auth/src/core/auth/auth_impl.ts index a402928ca99..cb111816c84 100644 --- a/packages/auth/src/core/auth/auth_impl.ts +++ b/packages/auth/src/core/auth/auth_impl.ts @@ -638,18 +638,37 @@ export class AuthImpl implements AuthInternal, _FirebaseService { ? nextOrObserver : nextOrObserver.next.bind(nextOrObserver); + let isUnsubscribed = false; + const promise = this._isInitialized ? Promise.resolve() : this._initializationPromise; _assert(promise, this, AuthErrorCode.INTERNAL_ERROR); // The callback needs to be called asynchronously per the spec. // eslint-disable-next-line @typescript-eslint/no-floating-promises - promise.then(() => cb(this.currentUser)); + promise.then(() => { + if (isUnsubscribed) { + return; + } + cb(this.currentUser); + }); if (typeof nextOrObserver === 'function') { - return subscription.addObserver(nextOrObserver, error, completed); + const unsubscribe = subscription.addObserver( + nextOrObserver, + error, + completed + ); + return () => { + isUnsubscribed = true; + unsubscribe(); + }; } else { - return subscription.addObserver(nextOrObserver); + const unsubscribe = subscription.addObserver(nextOrObserver); + return () => { + isUnsubscribed = true; + unsubscribe(); + }; } } From d1eca5467f60c8d9774f6b6a8eb443d8ffc83fcb Mon Sep 17 00:00:00 2001 From: Christina Holland Date: Tue, 15 Aug 2023 14:07:45 -0700 Subject: [PATCH 15/15] Fix changeset checker to write to github output correctly (#7554) --- scripts/ci/check_changeset.ts | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/scripts/ci/check_changeset.ts b/scripts/ci/check_changeset.ts index 86756d64ea7..0c514af0a89 100644 --- a/scripts/ci/check_changeset.ts +++ b/scripts/ci/check_changeset.ts @@ -124,11 +124,11 @@ async function main() { const errors = []; try { await exec(`yarn changeset status`); - console.log(`"BLOCKING_FAILURE=false" >> $GITHUB_OUTPUT`); + await exec(`echo "BLOCKING_FAILURE=false" >> $GITHUB_OUTPUT`); } catch (e) { const error = e as Error; if (error.message.match('No changesets present')) { - console.log(`"BLOCKING_FAILURE=false" >> $GITHUB_OUTPUT`); + await exec(`echo "BLOCKING_FAILURE=false" >> $GITHUB_OUTPUT`); } else { const messageLines = error.message.replace(/🦋 error /g, '').split('\n'); let formattedStatusError = @@ -147,9 +147,9 @@ async function main() { /** * Sets Github Actions output for a step. Pass changeset error message to next * step. See: - * https://github.com/actions/toolkit/blob/master/docs/commands.md#set-outputs + * https://docs.github.com/en/actions/using-workflows/workflow-commands-for-github-actions#setting-an-output-parameter */ - console.log(`"BLOCKING_FAILURE=true" >> $GITHUB_OUTPUT`); + await exec(`echo "BLOCKING_FAILURE=true" >> $GITHUB_OUTPUT`); } } @@ -185,13 +185,13 @@ async function main() { `- Package ${bumpPackage} has a ${bumpText} bump which requires an ` + `additional line to bump the main "firebase" package to ${bumpText}.` ); - console.log(`"BLOCKING_FAILURE=true" >> $GITHUB_OUTPUT`); + await exec(`echo "BLOCKING_FAILURE=true" >> $GITHUB_OUTPUT`); } else if (bumpRank[changesetPackages['firebase']] < highestBump) { errors.push( `- Package ${bumpPackage} has a ${bumpText} bump. ` + `Increase the bump for the main "firebase" package to ${bumpText}.` ); - console.log(`"BLOCKING_FAILURE=true" >> $GITHUB_OUTPUT`); + await exec(`echo "BLOCKING_FAILURE=true" >> $GITHUB_OUTPUT`); } } } @@ -203,11 +203,11 @@ async function main() { /** * Sets Github Actions output for a step. Pass changeset error message to next * step. See: - * https://github.com/actions/toolkit/blob/master/docs/commands.md#set-outputs + * https://docs.github.com/en/actions/using-workflows/workflow-commands-for-github-actions#setting-an-output-parameter */ if (errors.length > 0) - console.log( - `"CHANGESET_ERROR_MESSAGE=${errors.join('%0A')}" >> $GITHUB_OUTPUT` + await exec( + `echo "CHANGESET_ERROR_MESSAGE=${errors.join('%0A')}" >> $GITHUB_OUTPUT` ); process.exit(); }